From 7a503b2c88e7dcc53defb120ea0f174730eb3542 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 Signed-off-by: Aofei Sheng --- docs/openapi.yaml | 55 +- .../cmd/spx-backend/get_assets_list.yap | 7 +- .../cmd/spx-backend/get_projects_list.yap | 4 +- spx-backend/cmd/spx-backend/gop_autogen.go | 199 ++-- spx-backend/cmd/spx-backend/middleware.go | 2 +- spx-backend/cmd/spx-backend/post_asset.yap | 10 +- .../cmd/spx-backend/post_asset_#id_click.yap | 12 - spx-backend/cmd/spx-backend/post_project.yap | 10 +- spx-backend/cmd/spx-backend/util.go | 7 +- spx-backend/go.mod | 5 +- spx-backend/go.sum | 13 +- spx-backend/init.sql | 166 +++- spx-backend/internal/controller/aigc_test.go | 106 --- spx-backend/internal/controller/asset.go | 395 +++++--- spx-backend/internal/controller/asset_test.go | 899 ------------------ spx-backend/internal/controller/controller.go | 67 +- .../internal/controller/controller_test.go | 83 -- spx-backend/internal/controller/project.go | 791 +++++++++++++-- .../internal/controller/project_test.go | 608 ------------ spx-backend/internal/controller/user.go | 376 +++++++- spx-backend/internal/controller/user_test.go | 94 -- spx-backend/internal/controller/util_test.go | 155 --- spx-backend/internal/model/asset.go | 118 +-- 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 | 87 +- .../model/{file_test.go => model_test.go} | 0 spx-backend/internal/model/project.go | 132 +-- 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 | 56 ++ 37 files changed, 1893 insertions(+), 4081 deletions(-) delete mode 100644 spx-backend/cmd/spx-backend/post_asset_#id_click.yap delete mode 100644 spx-backend/internal/controller/aigc_test.go delete mode 100644 spx-backend/internal/controller/asset_test.go delete mode 100644 spx-backend/internal/controller/controller_test.go delete mode 100644 spx-backend/internal/controller/project_test.go delete mode 100644 spx-backend/internal/controller/user_test.go delete mode 100644 spx-backend/internal/controller/util_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/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 diff --git a/docs/openapi.yaml b/docs/openapi.yaml index c0315872b..a46b38e25 100644 --- a/docs/openapi.yaml +++ b/docs/openapi.yaml @@ -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: @@ -349,12 +352,9 @@ paths: schema: type: object required: - - name - files - isPublic properties: - name: - $ref: "#/components/schemas/Project/properties/name" files: $ref: "#/components/schemas/Project/properties/files" isPublic: @@ -598,8 +598,6 @@ paths: $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" responses: @@ -626,7 +624,10 @@ 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: category in: query schema: @@ -661,11 +662,10 @@ paths: schema: type: string enum: - - default - - time - - clickCount + - cTime + - uTime examples: - - default + - cTime description: Field by which to order the results. - $ref: "#/components/parameters/SortOrder" - $ref: "#/components/parameters/PageIndex" @@ -742,8 +742,6 @@ paths: $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" responses: @@ -769,23 +767,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: @@ -1023,6 +1004,7 @@ components: $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 @@ -1186,21 +1168,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. UpInfo: type: object diff --git a/spx-backend/cmd/spx-backend/get_assets_list.yap b/spx-backend/cmd/spx-backend/get_assets_list.yap index 4b67d4cd7..acc1d99af 100644 --- a/spx-backend/cmd/spx-backend/get_assets_list.yap +++ b/spx-backend/cmd/spx-backend/get_assets_list.yap @@ -15,7 +15,10 @@ ctx := &Context user, _ := controller.UserFromContext(ctx.Context()) params := &controller.ListAssetsParams{} -params.Keyword = ${keyword} +keyword := ${keyword} +if keyword != "" { + params.Keyword = &keyword +} switch owner := ${owner}; owner { case "": @@ -23,7 +26,7 @@ case "": replyWithCode(ctx, errorUnauthorized) return } - params.Owner = &user.Name + params.Owner = &user.Username case "*": params.Owner = nil default: diff --git a/spx-backend/cmd/spx-backend/get_projects_list.yap b/spx-backend/cmd/spx-backend/get_projects_list.yap index 16ee50813..54ebf5c89 100644 --- a/spx-backend/cmd/spx-backend/get_projects_list.yap +++ b/spx-backend/cmd/spx-backend/get_projects_list.yap @@ -13,7 +13,7 @@ import ( ctx := &Context user, _ := controller.UserFromContext(ctx.Context()) -params := &controller.ListProjectsParams{} +params := controller.NewListProjectsParams() if isPublicParam := ${isPublic}; isPublicParam != "" { isPublicInt, err := strconv.Atoi(isPublicParam) @@ -31,7 +31,7 @@ case "": replyWithCode(ctx, errorUnauthorized) return } - params.Owner = &user.Name + params.Owner = &user.Username case "*": params.Owner = nil default: diff --git a/spx-backend/cmd/spx-backend/gop_autogen.go b/spx-backend/cmd/spx-backend/gop_autogen.go index 138c17bef..0d693b036 100644 --- a/spx-backend/cmd/spx-backend/gop_autogen.go +++ b/spx-backend/cmd/spx-backend/gop_autogen.go @@ -64,10 +64,6 @@ type post_asset struct { yap.Handler *AppV2 } -type post_asset_id_click struct { - yap.Handler - *AppV2 -} type post_project struct { yap.Handler *AppV2 @@ -145,7 +141,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(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_project), new(post_util_fileurls), new(post_util_fmtcode), new(put_asset_id), new(put_project_owner_name)) } //line cmd/spx-backend/delete_asset_#id.yap:6 func (this *delete_asset_id) Main(_gop_arg0 *yap.Context) { @@ -217,111 +213,116 @@ func (this *get_assets_list) Main(_gop_arg0 *yap.Context) { //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") + keyword := this.Gop_Env("keyword") +//line cmd/spx-backend/get_assets_list.yap:19:1 + if keyword != "" { //line cmd/spx-backend/get_assets_list.yap:20:1 + params.Keyword = &keyword + } +//line cmd/spx-backend/get_assets_list.yap:23:1 switch -//line cmd/spx-backend/get_assets_list.yap:20:1 +//line cmd/spx-backend/get_assets_list.yap:23: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:24:1 case "": -//line cmd/spx-backend/get_assets_list.yap:22:1 +//line cmd/spx-backend/get_assets_list.yap:25:1 if user == nil { -//line cmd/spx-backend/get_assets_list.yap:23:1 +//line cmd/spx-backend/get_assets_list.yap:26:1 replyWithCode(ctx, errorUnauthorized) -//line cmd/spx-backend/get_assets_list.yap:24:1 +//line cmd/spx-backend/get_assets_list.yap:27:1 return } -//line cmd/spx-backend/get_assets_list.yap:26:1 - params.Owner = &user.Name -//line cmd/spx-backend/get_assets_list.yap:27:1 +//line cmd/spx-backend/get_assets_list.yap:29:1 + params.Owner = &user.Username +//line cmd/spx-backend/get_assets_list.yap:30:1 case "*": -//line cmd/spx-backend/get_assets_list.yap:28:1 +//line cmd/spx-backend/get_assets_list.yap:31:1 params.Owner = nil -//line cmd/spx-backend/get_assets_list.yap:29:1 +//line cmd/spx-backend/get_assets_list.yap:32:1 default: -//line cmd/spx-backend/get_assets_list.yap:30:1 +//line cmd/spx-backend/get_assets_list.yap:33:1 params.Owner = &owner } -//line cmd/spx-backend/get_assets_list.yap:33:1 +//line cmd/spx-backend/get_assets_list.yap:36:1 if -//line cmd/spx-backend/get_assets_list.yap:33:1 +//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 +//line cmd/spx-backend/get_assets_list.yap:40:1 assetTypeParam := this.Gop_Env("assetType"); assetTypeParam != "" { -//line cmd/spx-backend/get_assets_list.yap:38:1 +//line cmd/spx-backend/get_assets_list.yap:41:1 assetTypeInt, err := strconv.Atoi(assetTypeParam) -//line cmd/spx-backend/get_assets_list.yap:39:1 +//line cmd/spx-backend/get_assets_list.yap:42:1 if err != nil { -//line cmd/spx-backend/get_assets_list.yap:40:1 +//line cmd/spx-backend/get_assets_list.yap:43:1 replyWithCode(ctx, errorInvalidArgs) -//line cmd/spx-backend/get_assets_list.yap:41:1 +//line cmd/spx-backend/get_assets_list.yap:44:1 return } -//line cmd/spx-backend/get_assets_list.yap:43:1 +//line cmd/spx-backend/get_assets_list.yap:46:1 assetType := model.AssetType(assetTypeInt) -//line cmd/spx-backend/get_assets_list.yap:44:1 +//line cmd/spx-backend/get_assets_list.yap:47:1 params.AssetType = &assetType } -//line cmd/spx-backend/get_assets_list.yap:47:1 +//line cmd/spx-backend/get_assets_list.yap:50:1 if -//line cmd/spx-backend/get_assets_list.yap:47:1 +//line cmd/spx-backend/get_assets_list.yap:50:1 filesHash := this.Gop_Env("filesHash"); filesHash != "" { -//line cmd/spx-backend/get_assets_list.yap:48:1 +//line cmd/spx-backend/get_assets_list.yap:51:1 params.FilesHash = &filesHash } -//line cmd/spx-backend/get_assets_list.yap:51:1 +//line cmd/spx-backend/get_assets_list.yap:54:1 if -//line cmd/spx-backend/get_assets_list.yap:51:1 +//line cmd/spx-backend/get_assets_list.yap:54:1 isPublicParam := this.Gop_Env("isPublic"); isPublicParam != "" { -//line cmd/spx-backend/get_assets_list.yap:52:1 +//line cmd/spx-backend/get_assets_list.yap:55:1 isPublicInt, err := strconv.Atoi(isPublicParam) -//line cmd/spx-backend/get_assets_list.yap:53:1 +//line cmd/spx-backend/get_assets_list.yap:56:1 if err != nil { -//line cmd/spx-backend/get_assets_list.yap:54:1 +//line cmd/spx-backend/get_assets_list.yap:57:1 replyWithCode(ctx, errorInvalidArgs) -//line cmd/spx-backend/get_assets_list.yap:55:1 +//line cmd/spx-backend/get_assets_list.yap:58:1 return } -//line cmd/spx-backend/get_assets_list.yap:57:1 +//line cmd/spx-backend/get_assets_list.yap:60:1 isPublic := model.IsPublic(isPublicInt) -//line cmd/spx-backend/get_assets_list.yap:58:1 +//line cmd/spx-backend/get_assets_list.yap:61:1 params.IsPublic = &isPublic } -//line cmd/spx-backend/get_assets_list.yap:61:1 +//line cmd/spx-backend/get_assets_list.yap:64:1 if -//line cmd/spx-backend/get_assets_list.yap:61:1 +//line cmd/spx-backend/get_assets_list.yap:64:1 orderBy := this.Gop_Env("orderBy"); orderBy != "" { -//line cmd/spx-backend/get_assets_list.yap:62:1 +//line cmd/spx-backend/get_assets_list.yap:65:1 params.OrderBy = controller.ListAssetsOrderBy(orderBy) } -//line cmd/spx-backend/get_assets_list.yap:65:1 +//line cmd/spx-backend/get_assets_list.yap:68:1 params.Pagination.Index = ctx.ParamInt("pageIndex", firstPageIndex) -//line cmd/spx-backend/get_assets_list.yap:66:1 +//line cmd/spx-backend/get_assets_list.yap:69:1 params.Pagination.Size = ctx.ParamInt("pageSize", defaultPageSize) -//line cmd/spx-backend/get_assets_list.yap:67:1 +//line cmd/spx-backend/get_assets_list.yap:70:1 if -//line cmd/spx-backend/get_assets_list.yap:67:1 +//line cmd/spx-backend/get_assets_list.yap:70:1 ok, msg := params.Validate(); !ok { -//line cmd/spx-backend/get_assets_list.yap:68:1 +//line cmd/spx-backend/get_assets_list.yap:71:1 replyWithCodeMsg(ctx, errorInvalidArgs, msg) -//line cmd/spx-backend/get_assets_list.yap:69:1 +//line cmd/spx-backend/get_assets_list.yap:72:1 return } -//line cmd/spx-backend/get_assets_list.yap:72:1 +//line cmd/spx-backend/get_assets_list.yap:75:1 assets, err := this.ctrl.ListAssets(ctx.Context(), params) -//line cmd/spx-backend/get_assets_list.yap:73:1 +//line cmd/spx-backend/get_assets_list.yap:76:1 if err != nil { -//line cmd/spx-backend/get_assets_list.yap:74:1 +//line cmd/spx-backend/get_assets_list.yap:77:1 replyWithInnerError(ctx, err) -//line cmd/spx-backend/get_assets_list.yap:75:1 +//line cmd/spx-backend/get_assets_list.yap:78:1 return } -//line cmd/spx-backend/get_assets_list.yap:77:1 +//line cmd/spx-backend/get_assets_list.yap:80:1 this.Json__1(assets) } func (this *get_assets_list) Classfname() string { @@ -355,7 +356,7 @@ 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 //line cmd/spx-backend/get_projects_list.yap:18:1 @@ -388,7 +389,7 @@ func (this *get_projects_list) Main(_gop_arg0 *yap.Context) { return } //line cmd/spx-backend/get_projects_list.yap:34:1 - params.Owner = &user.Name + params.Owner = &user.Username //line cmd/spx-backend/get_projects_list.yap:35:1 case "*": //line cmd/spx-backend/get_projects_list.yap:36:1 @@ -502,105 +503,81 @@ 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 +//line cmd/spx-backend/post_asset.yap:30:1 this.Json__1(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) { - this.Handler.Main(_gop_arg0) -//line cmd/spx-backend/post_asset_#id_click.yap:6:1 - ctx := &this.Context -//line cmd/spx-backend/post_asset_#id_click.yap:8: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 - replyWithInnerError(ctx, err) -//line cmd/spx-backend/post_asset_#id_click.yap:10:1 - return - } -//line cmd/spx-backend/post_asset_#id_click.yap:12:1 - this.Json__1(nil) -} -func (this *post_asset_id_click) Classfname() string { - return "post_asset_#id_click" -} //line cmd/spx-backend/post_project.yap:10 func (this *post_project) Main(_gop_arg0 *yap.Context) { this.Handler.Main(_gop_arg0) //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 +//line cmd/spx-backend/post_project.yap:30:1 this.Json__1(project) } func (this *post_project) Classfname() string { 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..696f4485f 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,22 +9,20 @@ 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 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.yap b/spx-backend/cmd/spx-backend/post_project.yap index 5d9e269b4..ee0504d67 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,22 +9,20 @@ 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 diff --git a/spx-backend/cmd/spx-backend/util.go b/spx-backend/cmd/spx-backend/util.go index 6dee9899d..a70f4d16d 100644 --- a/spx-backend/cmd/spx-backend/util.go +++ b/spx-backend/cmd/spx-backend/util.go @@ -8,10 +8,11 @@ import ( "github.com/goplus/builder/spx-backend/internal/controller" "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,13 +55,13 @@ 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: replyWithCode(ctx, errorUnknown) diff --git a/spx-backend/go.mod b/spx-backend/go.mod index d8c541c9b..43f3c6140 100644 --- a/spx-backend/go.mod +++ b/spx-backend/go.mod @@ -16,11 +16,12 @@ require ( ) require ( - github.com/DATA-DOG/go-sqlmock v1.5.2 github.com/casdoor/casdoor-go-sdk v0.36.0 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 +32,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..564621016 100644 --- a/spx-backend/go.sum +++ b/spx-backend/go.sum @@ -12,8 +12,6 @@ cloud.google.com/go/storage v1.35.1/go.mod h1:M6M/3V/D3KpzMTJyPOR/HU6n2Si5QdaXYE filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= -github.com/DATA-DOG/go-sqlmock v1.5.2 h1:OcvFkGmslmlZibjAjaHm3L//6LiuBgolP7OputlJIzU= -github.com/DATA-DOG/go-sqlmock v1.5.2/go.mod h1:88MAG/4G7SMwSE3CeA0ZKzrT5CiOU3OJ+JlNzwDqpNU= github.com/aws/aws-sdk-go v1.49.0 h1:g9BkW1fo9GqKfwg2+zCD+TW/D36Ux+vtfJ8guF4AYmY= github.com/aws/aws-sdk-go v1.49.0/go.mod h1:LF8svs817+Nz+DmiMQKTO3ubZ/6IaTpq3TjupRn3Eqk= github.com/aws/aws-sdk-go-v2 v1.24.0 h1:890+mqQ+hTpNuw0gGP6/4akolQkSToDJgHfQE7AwGuk= @@ -75,6 +73,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,11 +126,14 @@ 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= github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= -github.com/kisielk/sqlstruct v0.0.0-20201105191214-5f3e10d3ab46/go.mod h1:yyMNCyc/Ib3bDTKd379tNMpB/7/H5TjM2Y9QJ5THLbE= github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0= @@ -291,5 +293,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 index e6d97874b..133382132 100644 --- a/spx-backend/init.sql +++ b/spx-backend/init.sql @@ -1,42 +1,130 @@ -SET NAMES utf8mb4; -SET FOREIGN_KEY_CHECKS = 0; +SET NAMES utf8mb4 COLLATE utf8mb4_unicode_ci; --- ---------------------------- --- 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 user +-- +CREATE TABLE `user` ( + `id` BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY, + `c_time` DATETIME NOT NULL, + `u_time` DATETIME NOT NULL, + + `username` TEXT, + `description` TEXT, + + `follower_count` BIGINT, + `following_count` BIGINT, + + `project_count` BIGINT, + `public_project_count` BIGINT, + `liked_project_count` BIGINT, + + UNIQUE KEY `idx_user_username` (`username`) +) CHARACTER SET = utf8mb4 COLLATE = utf8mb4_unicode_ci; + +-- +-- Table structure for user relationship +-- +CREATE TABLE `user_relationship` ( + `id` BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY, + `c_time` DATETIME NOT NULL, + `u_time` DATETIME NOT NULL, + + `user_id` BIGINT, + `target_user_id` BIGINT, + + `followed_at` DATETIME, + + UNIQUE KEY `idx_user_relationship_user_id_target_user_id` (`user_id`, `target_user_id`) +) CHARACTER SET = utf8mb4 COLLATE = utf8mb4_unicode_ci; + +-- -- 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; + `id` BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY, + `c_time` DATETIME NOT NULL, + `u_time` DATETIME NOT NULL, + + -- TODO: legacy fields, remove after migration + `owner` TEXT, -- TODO: migrate to `owner_id` + + `owner_id` BIGINT, + `remixed_from_release_id` BIGINT, + + `name` TEXT, + `version` INT, + `files` JSON, + `is_public` TINYINT, + `status` TINYINT, + `description` TEXT, + `instructions` TEXT, + `thumbnail` TEXT, + + `view_count` BIGINT, + `like_count` BIGINT, + `release_count` BIGINT, + `remix_count` BIGINT +) CHARACTER SET = utf8mb4 COLLATE = utf8mb4_unicode_ci; + +-- +-- Table structure for user project relationship +-- +CREATE TABLE `user_project_relationship` ( + `id` BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY, + `c_time` DATETIME NOT NULL, + `u_time` DATETIME NOT NULL, + + `user_id` BIGINT, + `project_id` BIGINT, + + `view_count` BIGINT, + `last_viewed_at` DATETIME, + `liked_at` DATETIME, + + UNIQUE KEY `idx_user_project_relationship_user_id_project_id` (`user_id`, `project_id`) +) CHARACTER SET = utf8mb4 COLLATE = utf8mb4_unicode_ci; + +-- +-- Table structure for project release +-- +CREATE TABLE `project_release` ( + `id` BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY, + `c_time` DATETIME NOT NULL, + `u_time` DATETIME NOT NULL, + + `project_id` BIGINT, + + `name` TEXT, + `description` TEXT, + `files` JSON, + `thumbnail` TEXT, + + `remix_count` BIGINT, + + UNIQUE KEY `idx_project_release_project_id_name` (`project_id`, `name`) +) CHARACTER SET = utf8mb4 COLLATE = utf8mb4_unicode_ci; + +-- +-- Table structure for asset +-- +CREATE TABLE `asset` ( + `id` BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY, + `c_time` DATETIME NOT NULL, + `u_time` DATETIME NOT NULL, + + -- TODO: legacy fields, remove after migration + `owner` TEXT, -- TODO: migrate to `owner_id` + + `owner_id` BIGINT, + + `display_name` TEXT, + `category` TEXT, + `asset_type` TINYINT, + `files` JSON, + `files_hash` TEXT, + `preview` TEXT, + `is_public` TINYINT, + `status` TINYINT, + + `click_count` BIGINT +) CHARACTER SET = utf8mb4 COLLATE = utf8mb4_unicode_ci; diff --git a/spx-backend/internal/controller/aigc_test.go b/spx-backend/internal/controller/aigc_test.go deleted file mode 100644 index 2b4a48560..000000000 --- a/spx-backend/internal/controller/aigc_test.go +++ /dev/null @@ -1,106 +0,0 @@ -package controller - -import ( - "testing" - - "github.com/stretchr/testify/assert" -) - -func TestMattingParamsValidate(t *testing.T) { - t.Run("Normal", func(t *testing.T) { - params := &MattingParams{ - ImageUrl: "https://example.com/image.jpg", - } - ok, msg := params.Validate() - assert.True(t, ok) - assert.Empty(t, msg) - }) - - t.Run("EmptyImageUrl", func(t *testing.T) { - params := &MattingParams{} - ok, msg := params.Validate() - assert.False(t, ok) - assert.Equal(t, "missing imageUrl", msg) - }) - - t.Run("InvalidImageUrl", func(t *testing.T) { - params := &MattingParams{ - ImageUrl: "example.com/image.jpg", - } - ok, msg := params.Validate() - assert.False(t, ok) - assert.Equal(t, "invalid imageUrl", msg) - }) - - t.Run("InvalidImageUrl2", func(t *testing.T) { - params := &MattingParams{ - ImageUrl: "https://", - } - ok, msg := params.Validate() - assert.False(t, ok) - assert.Equal(t, "invalid imageUrl", msg) - }) - - t.Run("InvalidImageUrl3", func(t *testing.T) { - params := &MattingParams{ - ImageUrl: "image.jpg", - } - ok, msg := params.Validate() - assert.False(t, ok) - assert.Equal(t, "invalid imageUrl", msg) - }) - - t.Run("InvalidScheme", func(t *testing.T) { - params := &MattingParams{ - ImageUrl: "ftp://example.com/image.jpg", - } - ok, msg := params.Validate() - assert.False(t, ok) - assert.Equal(t, "invalid imageUrl: unsupported scheme", msg) - }) - - t.Run("LocalImageUrl", func(t *testing.T) { - params := &MattingParams{ - ImageUrl: "http://localhost:8080/a.jpg", - } - ok, msg := params.Validate() - assert.False(t, ok) - assert.Equal(t, "invalid imageUrl: private IP", msg) - }) - - t.Run("LocalImageUrl2", func(t *testing.T) { - params := &MattingParams{ - ImageUrl: "http://127.0.0.1:8080/a.jpg", - } - ok, msg := params.Validate() - assert.False(t, ok) - assert.Equal(t, "invalid imageUrl: private IP", msg) - }) - - t.Run("LocalImageUrl3", func(t *testing.T) { - params := &MattingParams{ - ImageUrl: "http://[::1]:8080/a.jpg", - } - ok, msg := params.Validate() - assert.False(t, ok) - assert.Equal(t, "invalid imageUrl: private IP", msg) - }) - - t.Run("LanImageUrl1", func(t *testing.T) { - params := &MattingParams{ - ImageUrl: "http://192.168.0.1:8080/a.jpg", - } - ok, msg := params.Validate() - assert.False(t, ok) - assert.Equal(t, "invalid imageUrl: private IP", msg) - }) - - t.Run("LanImageUrl2", func(t *testing.T) { - params := &MattingParams{ - ImageUrl: "http://[fe80::1]:8080/a.jpg", - } - ok, msg := params.Validate() - assert.False(t, ok) - assert.Equal(t, "invalid imageUrl: private IP", msg) - }) -} diff --git a/spx-backend/internal/controller/asset.go b/spx-backend/internal/controller/asset.go index 510f09146..dcbccfc27 100644 --- a/spx-backend/internal/controller/asset.go +++ b/spx-backend/internal/controller/asset.go @@ -2,191 +2,282 @@ package controller import ( "context" + "fmt" + "maps" "regexp" + "strconv" + "time" - "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"` + Category string `json:"category"` + AssetType model.AssetType `json:"assetType"` + Files model.FileCollection `json:"files"` + FilesHash string `json:"filesHash"` + IsPublic model.IsPublic `json:"isPublic"` +} + +// 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, + Category: mAsset.Category, + AssetType: mAsset.AssetType, + Files: mAsset.Files, + FilesHash: mAsset.FilesHash, + IsPublic: mAsset.IsPublic, + } +} + // 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). + Where("status = ?", model.StatusNormal). + 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.IsPublic == model.Personal { + 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"` + 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 *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 p.Category == "" { + return false, "missing category" + } + if !p.AssetType.IsValid() { + return false, "invalid assetType" + } + if p.FilesHash == "" { + return false, "missing filesHash" + } + if !p.IsPublic.IsValid() { + return false, "invalid isPublic" + } + 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, + Category: params.Category, + AssetType: params.AssetType, + Files: params.Files, + FilesHash: params.FilesHash, + IsPublic: params.IsPublic, + Status: model.StatusNormal, + } + mAsset.CTime = time.Now().UTC() + mAsset.UTime = mAsset.CTime + 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 ( + ListAssetsOrderByCTime ListAssetsOrderBy = "cTime" + ListAssetsOrderByUTime ListAssetsOrderBy = "uTime" ) +// IsValid reports whether the order by condition is valid. +func (ob ListAssetsOrderBy) IsValid() bool { + switch ob { + case ListAssetsOrderByCTime, ListAssetsOrderByUTime: + 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 filters assets by category. + // + // Applied only if non-nil. Category *string - // AccessType is the access type filter, applied only if non-nil. + // AssetType filters assets by type. + // + // Applied only if non-nil. AssetType *model.AssetType - // 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 filters assets by visibility status. + // + // Applied only if non-nil. IsPublic *model.IsPublic - // 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 } // Validate validates the parameters. func (p *ListAssetsParams) Validate() (ok bool, msg string) { + if p.AssetType != nil && !p.AssetType.IsValid() { + return false, "invalid assetType" + } + if p.IsPublic != nil && !p.IsPublic.IsValid() { + return false, "invalid isPublic" + } + if !p.OrderBy.IsValid() { + return false, "invalid order by" + } + if !p.SortOrder.IsValid() { + return false, "invalid sort order" + } + 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 { + if mUser, ok := UserFromContext(ctx); !ok || params.Owner == nil || *params.Owner != mUser.Username { public := model.Public params.IsPublic = &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{}). + Where("status = ?", model.StatusNormal) + if params.Keyword != nil { + query = query.Where("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}) + query = query.Where("category = ?", *params.Category) } if params.AssetType != nil { - wheres = append(wheres, model.FilterCondition{Column: "asset_type", Operation: "=", Value: *params.AssetType}) + query = query.Where("asset_type = ?", *params.AssetType) } if params.FilesHash != nil { - wheres = append(wheres, model.FilterCondition{Column: "files_hash", Operation: "=", Value: *params.FilesHash}) + query = query.Where("files_hash = ?", *params.FilesHash) } if params.IsPublic != nil { - wheres = append(wheres, model.FilterCondition{Column: "is_public", Operation: "=", Value: *params.IsPublic}) + query = query.Where("is_public = ?", *params.IsPublic) } - - 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 ListAssetsOrderByCTime: + query = query.Order(fmt.Sprintf("c_time %s", params.SortOrder)) + case ListAssetsOrderByUTime: + query = query.Order(fmt.Sprintf("u_time %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" - } - switch p.AssetType { - case model.AssetTypeSprite, model.AssetTypeBackdrop, model.AssetTypeSound: - default: - return false, "invalid assetType" - } - if p.FilesHash == "" { - return false, "missing filesHash" + 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.IsPublic { - case model.Personal, model.Public: - default: - return false, "invalid isPublic" + assetDTOs := make([]AssetDTO, len(mAssets)) + for i, mAsset := range mAssets { + assetDTOs[i] = toAssetDTO(mAsset) } - 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. @@ -196,7 +287,6 @@ type UpdateAssetParams struct { AssetType model.AssetType `json:"assetType"` Files model.FileCollection `json:"files"` FilesHash string `json:"filesHash"` - Preview string `json:"preview"` IsPublic model.IsPublic `json:"isPublic"` } @@ -210,75 +300,74 @@ func (p *UpdateAssetParams) Validate() (ok bool, msg string) { if p.Category == "" { return false, "missing category" } - switch p.AssetType { - case model.AssetTypeSprite, model.AssetTypeBackdrop, model.AssetTypeSound: - default: + if !p.AssetType.IsValid() { return false, "invalid assetType" } if p.FilesHash == "" { return false, "missing filesHash" } - switch p.IsPublic { - case model.Personal, model.Public: - default: + if !p.IsPublic.IsValid() { return false, "invalid isPublic" } 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.Category != mAsset.Category { + updates["category"] = params.Category } - return nil + if params.AssetType != mAsset.AssetType { + updates["asset_type"] = params.AssetType + } + if !maps.Equal(params.Files, mAsset.Files) { + updates["files"] = params.Files + } + if params.FilesHash != mAsset.FilesHash { + updates["files_hash"] = params.FilesHash + } + if params.IsPublic != mAsset.IsPublic { + updates["is_public"] = params.IsPublic + } + if len(updates) > 0 { + if queryResult := ctrl.db.WithContext(ctx).Model(mAsset).Updates(updates); queryResult.Error != nil { + return nil, fmt.Errorf("failed to update asset: %w", queryResult.Error) + } else if queryResult.RowsAffected == 0 { + return nil, ErrNotExist + } + } + 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 queryResult := ctrl.db.WithContext(ctx). + Model(mAsset). + Update("status", model.StatusDeleted); queryResult.Error != nil { + return fmt.Errorf("failed to delete asset: %w", queryResult.Error) + } else if queryResult.RowsAffected == 0 { + return ErrNotExist + } return nil } diff --git a/spx-backend/internal/controller/asset_test.go b/spx-backend/internal/controller/asset_test.go deleted file mode 100644 index c78b319bf..000000000 --- a/spx-backend/internal/controller/asset_test.go +++ /dev/null @@ -1,899 +0,0 @@ -package controller - -import ( - "context" - "database/sql" - "strings" - "testing" - - "github.com/DATA-DOG/go-sqlmock" - "github.com/goplus/builder/spx-backend/internal/model" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -func TestControllerEnsureAsset(t *testing.T) { - t.Run("Normal", 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([]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) - }) - - 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) - }) - - t.Run("NoUser", func(t *testing.T) { - ctrl, mock, err := newTestController(t) - require.NoError(t, err) - - 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) - }) - - t.Run("NoUserWithPublicAsset", func(t *testing.T) { - ctrl, mock, err := newTestController(t) - require.NoError(t, err) - - 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) - }) - - t.Run("NoUserWithPublicAssetButCheckOwner", func(t *testing.T) { - ctrl, mock, err := newTestController(t) - require.NoError(t, err) - - 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) - }) - - t.Run("NoUserWithPersonalAsset", func(t *testing.T) { - ctrl, mock, err := newTestController(t) - require.NoError(t, err) - - 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) - require.Error(t, err) - assert.ErrorIs(t, err, ErrUnauthorized) - }) -} - -func TestControllerGetAsset(t *testing.T) { - t.Run("Normal", 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([]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) - }) - - 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.GetAsset(ctx, "1") - require.Error(t, err) - assert.ErrorIs(t, err, model.ErrNotExist) - }) -} - -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}, - } - 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}, - } - 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) - }) - - 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}, - } - 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) - }) - - t.Run("NoOwner", func(t *testing.T) { - ctrl, mock, err := newTestController(t) - require.NoError(t, err) - - 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}, - } - 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) - }) - - t.Run("DifferentOwner", func(t *testing.T) { - ctrl, mock, err := newTestController(t) - require.NoError(t, err) - - 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}, - } - 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) - }) - - t.Run("ClosedDB", func(t *testing.T) { - ctrl, _, err := newTestController(t) - require.NoError(t, err) - ctrl.db.Close() - - 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}, - } - _, err = ctrl.ListAssets(ctx, params) - require.Error(t, err) - assert.EqualError(t, err, "sql: database is closed") - }) -} - -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, - } - 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, - } - ok, msg := params.Validate() - assert.False(t, ok) - assert.Equal(t, "missing displayName", 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, - } - ok, msg := params.Validate() - assert.False(t, ok) - assert.Equal(t, "invalid displayName", 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, - } - 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) - }) - - 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, - } - ok, msg := params.Validate() - assert.False(t, ok) - assert.Equal(t, "missing filesHash", 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, - } - ok, msg := params.Validate() - assert.False(t, ok) - assert.Equal(t, "invalid isPublic", msg) - }) -} - -func TestControllerAddAsset(t *testing.T) { - t.Run("Normal", func(t *testing.T) { - ctrl, mock, err := newTestController(t) - require.NoError(t, err) - - 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) - }) - - t.Run("NoUser", func(t *testing.T) { - ctrl, _, err := newTestController(t) - 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, - } - _, 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) - require.NoError(t, err) - - 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) - }) - - t.Run("ClosedDB", func(t *testing.T) { - ctrl, _, err := newTestController(t) - require.NoError(t, err) - ctrl.db.Close() - - 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) - require.Error(t, err) - assert.EqualError(t, err, "sql: database is closed") - }) -} - -func TestUpdateAssetParamsValidate(t *testing.T) { - t.Run("Normal", 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, - } - ok, msg := params.Validate() - assert.True(t, ok) - assert.Empty(t, msg) - }) - - t.Run("EmptyDisplayName", 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, - } - ok, msg := params.Validate() - assert.False(t, ok) - assert.Equal(t, "missing displayName", msg) - }) - - 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, - } - ok, msg := params.Validate() - assert.False(t, ok) - assert.Equal(t, "invalid displayName", msg) - }) - - t.Run("EmptyCategory", 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, - } - ok, msg := params.Validate() - assert.False(t, ok) - assert.Equal(t, "missing category", msg) - }) - - t.Run("InvalidAssetType", 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, - } - ok, msg := params.Validate() - assert.False(t, ok) - assert.Equal(t, "invalid assetType", msg) - }) - - t.Run("EmptyFilesHash", 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, - } - ok, msg := params.Validate() - assert.False(t, ok) - assert.Equal(t, "missing filesHash", msg) - }) - - t.Run("InvalidIsPublic", 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, - } - ok, msg := params.Validate() - assert.False(t, ok) - assert.Equal(t, "invalid isPublic", msg) - }) -} - -func TestControllerUpdateAsset(t *testing.T) { - t.Run("Normal", func(t *testing.T) { - ctrl, mock, err := newTestController(t) - require.NoError(t, err) - - 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", "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, - } - 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) - - 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) - - 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(nil)) - _, err = ctrl.UpdateAsset(ctx, "1", params) - require.Error(t, err) - assert.ErrorIs(t, err, model.ErrNotExist) - }) - - t.Run("ClosedConnForUpdateQuery", func(t *testing.T) { - ctrl, mock, err := newTestController(t) - require.NoError(t, err) - - 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", "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) - require.Error(t, err) - assert.ErrorIs(t, err, sql.ErrConnDone) - }) -} - -func TestControllerIncreaseAssetClickCount(t *testing.T) { - t.Run("Normal", 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([]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) - }) - - t.Run("NoUser", func(t *testing.T) { - ctrl, mock, err := newTestController(t) - require.NoError(t, err) - - 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) - }) - - t.Run("UnexpectedUser", 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([]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) - }) - - 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.IncreaseAssetClickCount(ctx, "1") - require.Error(t, err) - assert.ErrorIs(t, err, model.ErrNotExist) - }) - - t.Run("ClosedConnForUpdateQuery", 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([]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) - - 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) - - 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") - 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) - - 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) - }) - - 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.DeleteAsset(ctx, "1") - require.Error(t, err) - assert.ErrorIs(t, err, model.ErrNotExist) - }) - - t.Run("ClosedConnForUpdateQuery", 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([]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") - require.Error(t, err) - assert.ErrorIs(t, err, sql.ErrConnDone) - }) -} diff --git a/spx-backend/internal/controller/controller.go b/spx-backend/internal/controller/controller.go index 801202ac6..74ebc2dd1 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,9 +52,9 @@ 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. @@ -101,3 +105,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"` + CTime time.Time `json:"cTime"` + UTime time.Time `json:"uTime"` +} + +// toModelDTO converts the model to its DTO. +func toModelDTO(m model.Model) ModelDTO { + return ModelDTO{ + ID: strconv.FormatInt(m.ID, 10), + CTime: m.CTime, + UTime: m.UTime, + } +} + +// 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 deleted file mode 100644 index 1dafeda6a..000000000 --- a/spx-backend/internal/controller/controller_test.go +++ /dev/null @@ -1,83 +0,0 @@ -package controller - -import ( - "context" - "testing" - - "github.com/DATA-DOG/go-sqlmock" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -func setTestEnv(t *testing.T) { - t.Setenv("GOP_SPX_DSN", "root:root@tcp(mysql.example.com:3306)/builder?charset=utf8&parseTime=True") - t.Setenv("AIGC_ENDPOINT", "https://aigc.example.com") - - t.Setenv("KODO_AK", "fake-kodo-ak") - t.Setenv("KODO_SK", "fake-kodo-sk") - t.Setenv("KODO_BUCKET", "builder") - t.Setenv("KODO_BUCKET_REGION", "earth") - t.Setenv("KODO_BASE_URL", "https://kodo.example.com") - - t.Setenv("GOP_CASDOOR_ENDPOINT", "https://casdoor.example.com") - t.Setenv("GOP_CASDOOR_CLIENTID", "fake-client-id") - t.Setenv("GOP_CASDOOR_CLIENTSECRET", "fake-client-secret") - t.Setenv("GOP_CASDOOR_CERTIFICATE", `-----BEGIN CERTIFICATE----- -MIIDDDCCAfSgAwIBAgIPJTPotE6tfuppFEL608kZMA0GCSqGSIb3DQEBCwUAMCYx -DzANBgNVBAoTBkdvUGx1czETMBEGA1UEAxMKZ29wbHVzLm9yZzAgFw0yNDA1Mjcx -NjU3MjBaGA8yMTI0MDUwMzE2NTcyMFowJjEPMA0GA1UEChMGR29QbHVzMRMwEQYD -VQQDEwpnb3BsdXMub3JnMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA -0YRGxHFxL8Nkw5EiUhKaqB6NV4NuE98Nj6YL941GPLBeQLijyfJ2ErpWIqz7Hwzn -NVo76qPSKi/BpGw+EL4EgiWokW+Aw4lElkd7gnd/annQ3W6AC9v0bN7FPZUfpFAH -NmhveFfM5K78GXQqCEFeV+kxqxym4qhtCCK7Tb0uJ1dalX2INiANMJ5YfKWmjrkG -Mh9fGzw0+SL9gB1gkREap1Z2vwRD2HEuQd4J5G1YFyFkrISbI4X8xTVn6dSPUbCk -2aM9Fmg/ExD9mt7m3Klxm6iOnLojs1cRlFcexQtTjPbTnyKWtWSzF9WIegZC7aL9 -MwKN0mqvXyE8wnv4M/l72wIDAQABozUwMzAOBgNVHQ8BAf8EBAMCBaAwEwYDVR0l -BAwwCgYIKwYBBQUHAwEwDAYDVR0TAQH/BAIwADANBgkqhkiG9w0BAQsFAAOCAQEA -TpnkzMMKWceK7LScYuIcXEqCOwmzhT6yNRNTHzplta7NznjCVCL+kGffRlUQhdsN -pVGKPWbisd6y43IaokrkWOEPwhdWNa2rK9U7zYhI+9gGbqO02gJUw/gFIEjwO84J -uPrfGhKFB+ckitTWslFGf1d/Dt/MYS544QlB06IW8f+AM7z0sohh5nGH8lQIOmLC -7zQdehTX3TTuydAgBmU7a8oiM10t4Xw5alyy23Mo1qzZaZ1qHc2feJ8r9w1codAt -3c666GQTCYyifHL1dV2F5KullyFZ86SiOw3VDQ9D34Yd6YMvtBMkAjh7UZxMvun2 -VTh1XIl/IELBoZ+rQXozGA== ------END CERTIFICATE-----`) - t.Setenv("GOP_CASDOOR_ORGANIZATIONNAME", "fake-organization") - t.Setenv("GOP_CASDOOR_APPLICATONNAME", "fake-application") -} - -func newTestController(t *testing.T) (*Controller, sqlmock.Sqlmock, error) { - setTestEnv(t) - - db, mock, err := sqlmock.New() - if err != nil { - return nil, nil, err - } - t.Cleanup(func() { - db.Close() - }) - - ctrl, err := New(context.Background()) - if err != nil { - return nil, nil, err - } - ctrl.db = db - return ctrl, mock, nil -} - -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) - }) - - 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) - }) -} diff --git a/spx-backend/internal/controller/project.go b/spx-backend/internal/controller/project.go index 73792ebe7..481dc7a4b 100644 --- a/spx-backend/internal/controller/project.go +++ b/spx-backend/internal/controller/project.go @@ -2,180 +2,791 @@ 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" ) +// 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"` + IsPublic model.IsPublic `json:"isPublic"` + 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, + IsPublic: mProject.IsPublic, + Description: mProject.Description, + Instructions: mProject.Instructions, + Thumbnail: mProject.Thumbnail, + ViewCount: mProject.ViewCount, + LikeCount: mProject.LikeCount, + ReleaseCount: mProject.ReleaseCount, + RemixCount: mProject.RemixCount, + } +} + // projectNameRE is the regular expression for project name. var projectNameRE = regexp.MustCompile(`^[\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("name = ?", name). + Where("status = ?", model.StatusNormal). + 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.IsPublic == model.Personal { + 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"` + IsPublic model.IsPublic `json:"isPublic"` + 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 != "" { + parts := strings.Split(p.RemixSource, "/") + if l := len(parts); l < 2 || l > 3 { + return false, "invalid remixSource" + } + } + if p.Name == "" { + return false, "missing name" + } else if !projectNameRE.Match([]byte(p.Name)) { + return false, "invalid name" + } + if !p.IsPublic.IsValid() { + return false, "invalid isPublic" + } + 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, + IsPublic: params.IsPublic, + Status: model.StatusNormal, + Description: params.Description, + Instructions: params.Instructions, + Thumbnail: params.Thumbnail, + } + if params.RemixSource != "" { + parts := strings.Split(params.RemixSource, "/") + ownerUsername := parts[0] + projectName := parts[1] + + 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 = ?", ownerUsername). + Where("name = ?", projectName). + First(&mProject). + Error; err != nil { + return nil, fmt.Errorf("failed to get project %s/%s: %w", ownerUsername, projectName, err) + } + if mProject.OwnerID != mUser.ID && mProject.IsPublic == model.Personal { + return nil, ErrUnauthorized + } + + releaseQuery := ctrl.db.WithContext(ctx). + Model(&model.ProjectRelease{}). + Where("project_id = ?", mProject.ID) + if len(parts) == 3 { + releaseName := parts[2] + releaseQuery = releaseQuery.Where("name = ?", releaseName) + } else { + releaseQuery = releaseQuery.Order("c_time DESC") // latest release + } + + var mProjectRelease model.ProjectRelease + if err := releaseQuery.First(&mProjectRelease).Error; err != nil { + return nil, fmt.Errorf("failed to get release of project %s/%s: %w", ownerUsername, projectName, err) + } + + mProject.RemixedFromReleaseID = sql.NullInt64{ + Int64: mProjectRelease.ID, + Valid: true, + } + } + if err := ctrl.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + mProject.CTime = time.Now().UTC() + mProject.UTime = mProject.CTime + if err := tx.Create(&mProject).Error; err != nil { + return err + } + + userUpdates := map[string]any{ + "project_count": gorm.Expr("project_count + 1"), + } + if mProject.IsPublic == model.Public { + userUpdates["public_project_count"] = gorm.Expr("public_project_count + 1") + } + if queryResult := tx.Model(mUser).Updates(userUpdates); queryResult.Error != nil { + return queryResult.Error + } else if queryResult.RowsAffected == 0 { + return errors.New("failed to update user project count") + } + + if mProject.RemixedFromReleaseID.Valid { + if queryResult := tx. + Model(&model.ProjectRelease{}). + Where("id = ?", mProject.RemixedFromReleaseID.Int64). + Update("remix_count", gorm.Expr("remix_count + 1")); queryResult.Error != nil { + return queryResult.Error + } else if queryResult.RowsAffected == 0 { + return errors.New("failed to update remix count of remixed project release") + } + if queryResult := tx. + Model(&model.Project{}). + Joins("JOIN project_release ON project_release.project_id = project.id"). + Where("project_release.id = ?", mProject.RemixedFromReleaseID.Int64). + Update("remix_count", gorm.Expr("remix_count + 1")); queryResult.Error != nil { + return queryResult.Error + } else if queryResult.RowsAffected == 0 { + return errors.New("failed to update remix count of remixed project") + } + } + + 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 ( + ListProjectsOrderByCTime ListProjectsOrderBy = "cTime" + ListProjectsOrderByUTime ListProjectsOrderBy = "uTime" + 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 ListProjectsOrderByCTime, + ListProjectsOrderByUTime, + 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. + // 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 + + // IsPublic filters assets by visibility status. + // + // Applied only if non-nil. IsPublic *model.IsPublic + // 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: ListProjectsOrderByCTime, + SortOrder: SortOrderDesc, + Pagination: Pagination{Index: 1, Size: 20}, + } } // Validate validates the parameters. func (p *ListProjectsParams) Validate() (ok bool, msg string) { + if p.RemixedFrom != nil { + parts := strings.Split(*p.RemixedFrom, "/") + if l := len(parts); l < 2 || l > 3 { + return false, "invalid remixedFrom" + } + } + if p.IsPublic != nil && !p.IsPublic.IsValid() { + return false, "invalid isPublic" + } + if !p.OrderBy.IsValid() { + return false, "invalid order by" + } + if !p.SortOrder.IsValid() { + return false, "invalid sort order" + } + 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 { + if mUser, ok := UserFromContext(ctx); !ok || params.Owner == nil || *params.Owner != mUser.Username { public := model.Public params.IsPublic = &public } - var wheres []model.FilterCondition + query := ctrl.db.WithContext(ctx). + Model(&model.Project{}). + Preload("Owner"). + Preload("RemixedFromRelease.Project.Owner"). + Where("status = ?", model.StatusNormal) 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.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) + } + } + if params.Keyword != nil { + query = query.Where("name LIKE ?", "%"+*params.Keyword+"%") } if params.IsPublic != nil { - wheres = append(wheres, model.FilterCondition{Column: "is_public", Operation: "=", Value: *params.IsPublic}) + query = query.Where("is_public = ?", *params.IsPublic) } - - 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.Liker != nil { + query = query. + Joins("JOIN user_project_relationship ON user_project_relationship.project_id = project.id"). + Joins("JOIN user AS liker ON liker.id = user_project_relationship.user_id"). + Where("liker.username = ?", *params.Liker). + Where("user_project_relationship.liked_at IS NOT NULL") } - 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.CreatedAfter != nil { + query = query.Where("c_time > ?", *params.CreatedAfter) } - if p.Owner == "" { - return false, "missing owner" + if params.LikesReceivedAfter != nil { + query = query. + Joins("JOIN user_project_relationship ON user_project_relationship.project_id = project.id"). + Where("user_project_relationship.liked_at > ?", *params.LikesReceivedAfter). + Group("project.id") } - switch p.IsPublic { - case model.Personal, model.Public: - default: - return false, "invalid isPublic" + if params.RemixesReceivedAfter != nil { + query = query. + Joins("LEFT JOIN project_release ON project_release.project_id = project.id"). + Joins("LEFT JOIN project AS remixed_project ON remixed_project.remixed_from_release_id = project_release.id"). + Where("remixed_project.c_time > ?", *params.RemixesReceivedAfter). + Group("project.id") + } + if params.FromFollowees != nil && *params.FromFollowees { + if mUser, ok := UserFromContext(ctx); ok { + 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 ListProjectsOrderByCTime: + query = query.Order(fmt.Sprintf("c_time %s", params.SortOrder)) + case ListProjectsOrderByUTime: + query = query.Order(fmt.Sprintf("u_time %s", params.SortOrder)) + case ListProjectsOrderByLikeCount: + query = query.Order(fmt.Sprintf("like_count %s", params.SortOrder)) + case ListProjectsOrderByRemixCount: + query = query.Order(fmt.Sprintf("remix_count %s", params.SortOrder)) + case ListProjectsOrderByRecentLikeCount: + if params.LikesReceivedAfter == nil { + query = query.Order(fmt.Sprintf("like_count %s", params.SortOrder)) + break + } + query = query. + Joins("JOIN user_project_relationship ON user_project_relationship.project_id = project.id"). + Where("user_project_relationship.liked_at > ?", *params.LikesReceivedAfter). + Group("project.id"). + Order("COUNT(user_project_relationship.id) " + string(params.SortOrder)) + case ListProjectsOrderByRecentRemixCount: + if params.RemixesReceivedAfter == nil { + query = query.Order(fmt.Sprintf("remix_count %s", params.SortOrder)) + break + } + query = query. + Joins("JOIN project AS remixed_project ON remixed_project.remixed_from_release_id = project_release.id"). + Where("remixed_project.c_time > ?", *params.RemixesReceivedAfter). + Group("project.id"). + Order("COUNT(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"` + IsPublic model.IsPublic `json:"isPublic"` + 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: + if !p.IsPublic.IsValid() { return false, "invalid isPublic" } 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.IsPublic != mProject.IsPublic { + updates["is_public"] = params.IsPublic + } + 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).Updates(updates); queryResult.Error != nil { + return queryResult.Error + } else if queryResult.RowsAffected == 0 { + return ErrNotExist + } - 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["is_public"] != nil { + switch params.IsPublic { + case model.Personal: + userUpdates["public_project_count"] = gorm.Expr("public_project_count - 1") + case model.Public: + userUpdates["public_project_count"] = gorm.Expr("public_project_count + 1") + } + } + if len(userUpdates) > 0 { + if queryResult := tx.Model(mUser).Updates(userUpdates); queryResult.Error != nil { + return queryResult.Error + } else if queryResult.RowsAffected == 0 { + return errors.New("failed to update user public project count") + } + } + + 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 + } - project, err := ctrl.ensureProject(ctx, owner, name, true) + 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 queryResult := tx. + Model(mProject). + Update("status", model.StatusDeleted); queryResult.Error != nil { + return queryResult.Error + } else if queryResult.RowsAffected == 0 { + return ErrNotExist + } - if err := model.DeleteProjectByID(ctx, ctrl.db, project.ID); err != nil { - logger.Printf("failed to delete project: %v", err) - return err + userUpdates := map[string]any{ + "project_count": gorm.Expr("project_count - 1"), + } + if mProject.IsPublic == model.Public { + userUpdates["public_project_count"] = gorm.Expr("public_project_count - 1") + } + if queryResult := tx.Model(mUser).Updates(userUpdates); queryResult.Error != nil { + return queryResult.Error + } else if queryResult.RowsAffected == 0 { + return errors.New("failed to update user project count") + } + + if mProject.RemixedFromReleaseID.Valid { + if queryResult := tx. + Model(&model.ProjectRelease{}). + Where("id = ?", mProject.RemixedFromReleaseID.Int64). + Update("remix_count", gorm.Expr("remix_count - 1")); queryResult.Error != nil { + return queryResult.Error + } else if queryResult.RowsAffected == 0 { + return errors.New("failed to update remix count of remixed project release") + } + if queryResult := tx. + Model(&model.Project{}). + Joins("JOIN project_release ON project_release.project_id = project.id"). + Where("project_release.id = ?", mProject.RemixedFromReleaseID.Int64). + Update("remix_count", gorm.Expr("remix_count - 1")); queryResult.Error != nil { + return queryResult.Error + } else if queryResult.RowsAffected == 0 { + return errors.New("failed to update remix count of remixed project") + } + } + + 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("liked_project_count", gorm.Expr("liked_project_count - 1")). + 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("name = ?", name). + Where("status = ?", model.StatusNormal). + First(&mProject). + Error; err != nil { + return fmt.Errorf("failed to get project %s/%s: %w", owner, name, err) + } + + mUserProjectRelationship := model.UserProjectRelationship{ + UserID: mUser.ID, + ProjectID: mProject.ID, + } + mUserProjectRelationship.CTime = time.Now().UTC() + mUserProjectRelationship.UTime = mUserProjectRelationship.CTime + if err := ctrl.db.WithContext(ctx). + Where("user_id = ?", mUser.ID). + Where("project_id = ?", mProject.ID). + FirstOrCreate(&mUserProjectRelationship). + Error; err != nil { + return fmt.Errorf("failed to get/create user-project relationship: %w", 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{ + Time: time.Now().UTC(), + Valid: true, + }); queryResult.Error != nil { + return queryResult.Error + } else if queryResult.RowsAffected == 0 { + return errors.New("failed to update user-project relationship") + } + if queryResult := tx. + Model(mUser). + Update("liked_project_count", gorm.Expr("liked_project_count + 1")); queryResult.Error != nil { + return queryResult.Error + } else if queryResult.RowsAffected == 0 { + return errors.New("failed to update user liked project count") + } + if queryResult := tx. + Model(&mProject). + Update("like_count", gorm.Expr("like_count + 1")); queryResult.Error != nil { + return queryResult.Error + } else if queryResult.RowsAffected == 0 { + return errors.New("failed to update project like count") + } + 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("name = ?", name). + Where("status = ?", model.StatusNormal). + 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("name = ?", name). + Where("status = ?", model.StatusNormal). + First(&mProject). + Error; err != nil { + return fmt.Errorf("failed to get project %s/%s: %w", owner, name, err) + } + + mUserProjectRelationship := model.UserProjectRelationship{ + UserID: mUser.ID, + ProjectID: mProject.ID, + } + mUserProjectRelationship.CTime = time.Now().UTC() + mUserProjectRelationship.UTime = mUserProjectRelationship.CTime + if err := ctrl.db.WithContext(ctx). + Where("user_id = ?", mUser.ID). + Where("project_id = ?", mProject.ID). + FirstOrCreate(&mUserProjectRelationship). + Error; err != nil { + return fmt.Errorf("failed to get/create user-project relationship: %w", 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 errors.New("failed to update user-project relationship") + } + if queryResult := tx. + Model(mUser). + Update("liked_project_count", gorm.Expr("liked_project_count - 1")); queryResult.Error != nil { + return queryResult.Error + } else if queryResult.RowsAffected == 0 { + return errors.New("failed to update user liked project count") + } + if queryResult := tx. + Model(&mProject). + Update("like_count", gorm.Expr("like_count - 1")); queryResult.Error != nil { + return queryResult.Error + } else if queryResult.RowsAffected == 0 { + return errors.New("failed to update project like count") + } + return nil + }); err != nil { + return fmt.Errorf("failed to unlike project: %w", err) } return nil } diff --git a/spx-backend/internal/controller/project_test.go b/spx-backend/internal/controller/project_test.go deleted file mode 100644 index 8a1a00cc6..000000000 --- a/spx-backend/internal/controller/project_test.go +++ /dev/null @@ -1,608 +0,0 @@ -package controller - -import ( - "context" - "database/sql" - "testing" - - "github.com/DATA-DOG/go-sqlmock" - "github.com/goplus/builder/spx-backend/internal/model" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -func TestControllerEnsureProject(t *testing.T) { - t.Run("Normal", func(t *testing.T) { - ctrl, mock, err := newTestController(t) - require.NoError(t, err) - - 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) - }) - - t.Run("NoProject", func(t *testing.T) { - ctrl, mock, err := newTestController(t) - require.NoError(t, err) - - 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) - }) - - t.Run("NoUser", 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"}). - AddRow(1, "fake-project", "fake-name")) - _, err = ctrl.ensureProject(ctx, "fake-name", "fake-project", false) - require.Error(t, err) - assert.ErrorIs(t, err, ErrUnauthorized) - }) - - 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) - }) - - t.Run("NoUserWithPublicProjectButCheckOwner", 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)) - _, err = ctrl.ensureProject(ctx, "fake-name", "fake-project", true) - require.Error(t, err) - assert.ErrorIs(t, err, ErrUnauthorized) - }) - - t.Run("NoUserWithPersonalProject", 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.Personal)) - _, err = ctrl.ensureProject(ctx, "fake-name", "fake-project", false) - require.Error(t, err) - assert.ErrorIs(t, err, ErrUnauthorized) - }) -} - -func TestControllerGetProject(t *testing.T) { - t.Run("Normal", func(t *testing.T) { - ctrl, mock, err := newTestController(t) - require.NoError(t, err) - - 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) - }) - - t.Run("NoProject", func(t *testing.T) { - ctrl, mock, err := newTestController(t) - require.NoError(t, err) - - 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") - require.Error(t, err) - assert.ErrorIs(t, err, model.ErrNotExist) - }) -} - -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}, - } - ok, msg := params.Validate() - assert.True(t, ok) - assert.Empty(t, msg) - }) -} - -func TestControllerListProjects(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" - 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) - }) - - t.Run("NoUser", func(t *testing.T) { - ctrl, mock, err := newTestController(t) - require.NoError(t, err) - - 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) - require.NoError(t, err) - require.NotNil(t, projects) - assert.Len(t, projects.Data, 1) - assert.Equal(t, "1", projects.Data[0].ID) - }) - - t.Run("NoOwner", func(t *testing.T) { - ctrl, mock, err := newTestController(t) - require.NoError(t, err) - - 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) - require.NoError(t, err) - require.NotNil(t, projects) - assert.Len(t, projects.Data, 1) - assert.Equal(t, "1", projects.Data[0].ID) - }) - - t.Run("DifferentOwner", func(t *testing.T) { - ctrl, mock, err := newTestController(t) - require.NoError(t, err) - - 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) - }) - - t.Run("ClosedDB", func(t *testing.T) { - ctrl, _, err := newTestController(t) - require.NoError(t, err) - ctrl.db.Close() - - ctx := newContextWithTestUser(context.Background()) - paramsOwner := "fake-name" - paramsIsPublic := model.Personal - params := &ListProjectsParams{ - Owner: ¶msOwner, - IsPublic: ¶msIsPublic, - Pagination: model.Pagination{Index: 1, Size: 10}, - } - _, err = ctrl.ListProjects(ctx, params) - require.Error(t, err) - assert.EqualError(t, err, "sql: database is closed") - }) -} - -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, - } - 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, - } - ok, msg := params.Validate() - assert.False(t, ok) - assert.Equal(t, "missing name", msg) - }) - - t.Run("InvalidName", func(t *testing.T) { - params := &AddProjectParams{ - Name: "fake-name@", - Owner: "fake-owner", - Files: model.FileCollection{}, - IsPublic: model.Personal, - } - ok, msg := params.Validate() - assert.False(t, ok) - assert.Equal(t, "invalid name", msg) - }) - - t.Run("EmptyOwner", func(t *testing.T) { - params := &AddProjectParams{ - Name: "fake-name", - Owner: "", - Files: model.FileCollection{}, - IsPublic: model.Personal, - } - ok, msg := params.Validate() - assert.False(t, ok) - assert.Equal(t, "missing owner", msg) - }) - - t.Run("InvalidIsPublic", func(t *testing.T) { - params := &AddProjectParams{ - Name: "fake-name", - Owner: "fake-owner", - Files: model.FileCollection{}, - IsPublic: -1, - } - ok, msg := params.Validate() - assert.False(t, ok) - assert.Equal(t, "invalid isPublic", msg) - }) -} - -func TestControllerAddProject(t *testing.T) { - t.Run("Normal", func(t *testing.T) { - ctrl, mock, err := newTestController(t) - require.NoError(t, err) - - ctx := newContextWithTestUser(context.Background()) - params := &AddProjectParams{ - Name: "fake-project", - Owner: "fake-name", - Files: model.FileCollection{}, - IsPublic: model.Personal, - } - 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) - require.NoError(t, err) - require.NotNil(t, project) - assert.Equal(t, "1", project.ID) - }) - - t.Run("Exist", func(t *testing.T) { - ctrl, mock, err := newTestController(t) - require.NoError(t, err) - - ctx := newContextWithTestUser(context.Background()) - params := &AddProjectParams{ - Name: "fake-project", - Owner: "fake-name", - Files: model.FileCollection{}, - IsPublic: model.Personal, - } - 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) - require.Error(t, err) - assert.ErrorIs(t, err, model.ErrExist) - }) - - t.Run("NoUser", func(t *testing.T) { - ctrl, _, err := newTestController(t) - require.NoError(t, err) - - ctx := context.Background() - params := &AddProjectParams{ - Name: "fake-project", - Owner: "fake-name", - Files: model.FileCollection{}, - IsPublic: model.Personal, - } - _, err = ctrl.AddProject(ctx, params) - require.Error(t, err) - assert.ErrorIs(t, err, ErrUnauthorized) - }) - - t.Run("UnexpectedUser", func(t *testing.T) { - ctrl, _, err := newTestController(t) - require.NoError(t, err) - - ctx := newContextWithTestUser(context.Background()) - params := &AddProjectParams{ - Name: "fake-project", - Owner: "another-fake-name", - Files: model.FileCollection{}, - IsPublic: model.Personal, - } - _, err = ctrl.AddProject(ctx, params) - require.Error(t, err) - assert.ErrorIs(t, err, ErrForbidden) - }) - - t.Run("ClosedDB", func(t *testing.T) { - ctrl, _, err := newTestController(t) - require.NoError(t, err) - ctrl.db.Close() - - ctx := newContextWithTestUser(context.Background()) - params := &AddProjectParams{ - Name: "fake-project", - Owner: "fake-name", - Files: model.FileCollection{}, - IsPublic: model.Personal, - } - _, err = ctrl.AddProject(ctx, params) - require.Error(t, err) - assert.EqualError(t, err, "sql: database is closed") - }) -} - -func TestUpdateProjectParamsValidate(t *testing.T) { - t.Run("Normal", func(t *testing.T) { - params := &UpdateProjectParams{ - Files: model.FileCollection{}, - IsPublic: model.Personal, - } - ok, msg := params.Validate() - assert.True(t, ok) - assert.Empty(t, msg) - }) - - t.Run("InvalidIsPublic", func(t *testing.T) { - params := &UpdateProjectParams{ - Files: model.FileCollection{}, - IsPublic: -1, - } - ok, msg := params.Validate() - assert.False(t, ok) - assert.Equal(t, "invalid isPublic", msg) - }) -} - -func TestControllerUpdateProject(t *testing.T) { - t.Run("Normal", func(t *testing.T) { - ctrl, mock, err := newTestController(t) - require.NoError(t, err) - - 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"). - 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) - require.NoError(t, err) - require.NotNil(t, project) - assert.Equal(t, "1", project.ID) - assert.Equal(t, model.Public, project.IsPublic) - }) - - t.Run("NoUser", func(t *testing.T) { - ctrl, mock, err := newTestController(t) - require.NoError(t, err) - - ctx := 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)) - _, err = ctrl.UpdateProject(ctx, "fake-name", "fake-project", 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) - - 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", "another-fake-name", []byte("{}"), model.Personal)) - _, err = ctrl.UpdateProject(ctx, "fake-name", "fake-project", params) - require.Error(t, err) - assert.ErrorIs(t, err, ErrForbidden) - }) - - t.Run("NoProject", func(t *testing.T) { - ctrl, mock, err := newTestController(t) - require.NoError(t, err) - - 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(nil)) - _, err = ctrl.UpdateProject(ctx, "fake-name", "fake-project", params) - require.Error(t, err) - assert.ErrorIs(t, err, model.ErrNotExist) - }) - - t.Run("ClosedConnForUpdateQuery", func(t *testing.T) { - ctrl, mock, err := newTestController(t) - require.NoError(t, err) - - 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) - require.Error(t, err) - assert.ErrorIs(t, err, sql.ErrConnDone) - }) -} - -func TestControllerDeleteProject(t *testing.T) { - t.Run("Normal", func(t *testing.T) { - ctrl, mock, err := newTestController(t) - require.NoError(t, err) - - 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"). - WillReturnResult(sqlmock.NewResult(1, 1)) - err = ctrl.DeleteProject(ctx, "fake-name", "fake-project") - require.NoError(t, err) - }) - - t.Run("NoUser", 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"}). - AddRow(1, "fake-project", "fake-name")) - err = ctrl.DeleteProject(ctx, "fake-name", "fake-project") - 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) - - 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") - require.Error(t, err) - assert.ErrorIs(t, err, ErrForbidden) - }) - - t.Run("NoProject", func(t *testing.T) { - ctrl, mock, err := newTestController(t) - require.NoError(t, err) - - 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") - require.Error(t, err) - assert.ErrorIs(t, err, model.ErrNotExist) - }) - - t.Run("ClosedConnForUpdateQuery", func(t *testing.T) { - ctrl, mock, err := newTestController(t) - require.NoError(t, err) - - 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") - require.Error(t, err) - assert.ErrorIs(t, err, sql.ErrConnDone) - }) -} diff --git a/spx-backend/internal/controller/user.go b/spx-backend/internal/controller/user.go index 512fd53f9..ad46f2f76 100644 --- a/spx-backend/internal/controller/user.go +++ b/spx-backend/internal/controller/user.go @@ -2,46 +2,392 @@ 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 := model.User{ + Username: claims.Name, + } + mUser.CTime = time.Now().UTC() + mUser.UTime = mUser.CTime + if err := ctrl.db.WithContext(ctx). + Where("username = ?", claims.Name). + FirstOrCreate(&mUser). + Error; err != nil { + return nil, fmt.Errorf("failed to get/create user %s: %w", claims.Name, err) + } + return &mUser, nil +} + +// ListUsersOrderBy is the order by condition for listing users. +type ListUsersOrderBy string + +const ( + ListUsersOrderByCTime ListUsersOrderBy = "cTime" + ListUsersOrderByUTime ListUsersOrderBy = "uTime" + ListUsersOrderByFollowedAt ListUsersOrderBy = "followedAt" +) + +// IsValid reports whether the order by condition is valid. +func (ob ListUsersOrderBy) IsValid() bool { + switch ob { + case ListUsersOrderByCTime, ListUsersOrderByUTime, 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 +} + +// Validate validates the parameters. +func (p *ListUsersParams) Validate() (ok bool, msg string) { + if !p.OrderBy.IsValid() { + return false, "invalid order by" + } + if !p.SortOrder.IsValid() { + return false, "invalid sort order" + } + 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 ListUsersOrderByCTime: + query = query.Order(fmt.Sprintf("c_time %s", params.SortOrder)) + case ListUsersOrderByUTime: + query = query.Order(fmt.Sprintf("u_time %s", params.SortOrder)) + case ListUsersOrderByFollowedAt: + if _, ok := joinedTables["user_relationship"]; !ok { + query = query.Order(fmt.Sprintf("c_time %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 queryResult := ctrl.db.WithContext(ctx).Model(mUser).Updates(updates); queryResult.Error != nil { + return nil, fmt.Errorf("failed to update authenticated user %s: %w", mUser.Username, queryResult.Error) + } else if queryResult.RowsAffected == 0 { + return nil, ErrNotExist + } + } + 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). + Error; err != nil { + return fmt.Errorf("failed to get target user %s: %w", targetUsername, err) + } + + mUserRelationship := model.UserRelationship{ + UserID: mUser.ID, + TargetUserID: mTargetUser.ID, + } + mUserRelationship.CTime = time.Now().UTC() + mUserRelationship.UTime = mUserRelationship.CTime + if err := ctrl.db.WithContext(ctx). + Where("user_id = ?", mUser.ID). + Where("target_user_id = ?", mTargetUser.ID). + FirstOrCreate(&mUserRelationship). + Error; err != nil { + return fmt.Errorf("failed to get/create user relationship: %w", 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 errors.New("failed to update user relationship") + } + if queryResult := tx. + Model(mUser). + Update("following_count", gorm.Expr("following_count + 1")); queryResult.Error != nil { + return queryResult.Error + } else if queryResult.RowsAffected == 0 { + return errors.New("failed to update user following count") + } + if queryResult := tx. + Model(&mTargetUser). + Update("follower_count", gorm.Expr("follower_count + 1")); queryResult.Error != nil { + return queryResult.Error + } else if queryResult.RowsAffected == 0 { + return errors.New("failed to update target user follower count") + } + 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 + } + + mTargetUser, err := ctrl.GetUser(ctx, targetUsername) + if 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). + Error; err != nil { + return fmt.Errorf("failed to get target user %s: %w", targetUsername, err) + } + + mUserRelationship := model.UserRelationship{ + UserID: mUser.ID, + TargetUserID: mTargetUser.ID, + } + mUserRelationship.CTime = time.Now().UTC() + mUserRelationship.UTime = mUserRelationship.CTime + if err := ctrl.db.WithContext(ctx). + Where("user_id = ?", mUser.ID). + Where("target_user_id = ?", mTargetUser.ID). + FirstOrCreate(&mUserRelationship). + Error; err != nil { + return fmt.Errorf("failed to get/create user relationship: %w", 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 errors.New("failed to update user relationship") + } + if queryResult := tx. + Model(mUser). + Update("following_count", gorm.Expr("following_count - 1")); queryResult.Error != nil { + return queryResult.Error + } else if queryResult.RowsAffected == 0 { + return errors.New("failed to update user following count") + } + if queryResult := tx. + Model(&mTargetUser). + Update("follower_count", gorm.Expr("follower_count - 1")); queryResult.Error != nil { + return queryResult.Error + } else if queryResult.RowsAffected == 0 { + return errors.New("failed to update target user follower count") + } + 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 deleted file mode 100644 index d552ec093..000000000 --- a/spx-backend/internal/controller/user_test.go +++ /dev/null @@ -1,94 +0,0 @@ -package controller - -import ( - "context" - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -func newTestUser() *User { - return &User{ - Id: "fake-id", - Name: "fake-name", - Owner: "fake-owner", - } -} - -func newContextWithTestUser(ctx context.Context) context.Context { - return NewContextWithUser(ctx, newTestUser()) -} - -func TestNewContextWithUser(t *testing.T) { - t.Run("Normal", func(t *testing.T) { - ctx := NewContextWithUser(context.Background(), newTestUser()) - user, ok := ctx.Value(userContextKey).(*User) - require.True(t, ok) - require.NotNil(t, user) - assert.Equal(t, "fake-name", user.Name) - }) -} - -func TestUserFromContext(t *testing.T) { - t.Run("Normal", func(t *testing.T) { - ctx := newContextWithTestUser(context.Background()) - user, ok := UserFromContext(ctx) - require.True(t, ok) - require.NotNil(t, user) - assert.Equal(t, "fake-name", user.Name) - }) - - t.Run("NoUser", func(t *testing.T) { - user, ok := UserFromContext(context.Background()) - require.False(t, ok) - require.Nil(t, user) - }) -} - -func TestEnsureUser(t *testing.T) { - t.Run("Normal", func(t *testing.T) { - ctx := newContextWithTestUser(context.Background()) - user, err := EnsureUser(ctx, "fake-name") - require.NoError(t, err) - require.NotNil(t, user) - }) - - t.Run("NoUser", func(t *testing.T) { - _, err := EnsureUser(context.Background(), "fake-name") - require.Error(t, err) - 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) - assert.ErrorIs(t, err, ErrForbidden) - }) -} - -const fakeUserToken = "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9." + - "eyJvd25lciI6IkdvUGx1cyIsIm5hbWUiOiJmYWtlLW5hbWUiLCJpZCI6IjEiLCJpc3MiOiJHb1BsdXMiLCJzdWIiOiIxIiwiZXhwIjo0ODcwNDI5MDQwfQ." + - "X0T-v-RJggMRy3Mmui2FoRH-_4DQsNA6DekUx1BfIljTZaEbHbuW59dSlKQ-i2MuYD7_8mI18vZqT3iysbKQ1T70NF97B_A130ML3pulZWlj1ZokgjCkVug25QRbq_N7JMd4apJZFlyZj8Bd2VfqtAKMlJJ4HzKzNXB-GBogDVlKeu4xJ1BiXO2rHL1PNa5KyKLSSMXmuP_Wc108RXZ0BiKDE30IG1fvcyvudXcetmltuWjuU6JRj3FGedxuVEqZLXqcm13dCxHnuFV1x1XU9KExcDvVyVB91FpBe5npzYp6WMX0fx9vU1b4eJ69EZoeMdMolhmvYInT1G8r1PEmbg" - -func TestControllerUserFromToken(t *testing.T) { - t.Run("Normal", func(t *testing.T) { - ctrl, _, err := newTestController(t) - require.NoError(t, err) - - user, err := ctrl.UserFromToken(fakeUserToken) - require.NoError(t, err) - require.NotNil(t, user) - assert.Equal(t, "fake-name", user.Name) - }) - - t.Run("InvalidToken", func(t *testing.T) { - ctrl, _, err := newTestController(t) - require.NoError(t, err) - - _, err = ctrl.UserFromToken("invalid-token") - require.Error(t, err) - assert.EqualError(t, err, "ctrl.casdoorClient.ParseJwtToken failed: token contains an invalid number of segments") - }) -} diff --git a/spx-backend/internal/controller/util_test.go b/spx-backend/internal/controller/util_test.go deleted file mode 100644 index 14ef811ea..000000000 --- a/spx-backend/internal/controller/util_test.go +++ /dev/null @@ -1,155 +0,0 @@ -package controller - -import ( - "context" - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -func TestFmtCodeParamsValidate(t *testing.T) { - t.Run("Normal", func(t *testing.T) { - params := &FmtCodeParams{ - Body: "package main\n\nfunc main() {}\n", - } - ok, msg := params.Validate() - assert.True(t, ok) - assert.Empty(t, msg) - }) - - t.Run("EmptyBody", func(t *testing.T) { - params := &FmtCodeParams{} - ok, msg := params.Validate() - assert.False(t, ok) - assert.Equal(t, "missing body", msg) - }) -} - -func TestControllerFmtCode(t *testing.T) { - t.Run("Normal", func(t *testing.T) { - ctrl, _, err := newTestController(t) - require.NoError(t, err) - - formattedCode, err := ctrl.FmtCode(context.Background(), &FmtCodeParams{ - Body: "package main\n\nfunc main() {}\n", - }) - require.NoError(t, err) - require.NotNil(t, formattedCode) - require.NotEmpty(t, formattedCode.Body) - require.Nil(t, formattedCode.Error) - assert.Equal(t, "\n", formattedCode.Body) - }) - - t.Run("FormatError", func(t *testing.T) { - ctrl, _, err := newTestController(t) - require.NoError(t, err) - - formattedCode, err := ctrl.FmtCode(context.Background(), &FmtCodeParams{ - Body: "package main\n\nfunc main() {", - }) - require.NoError(t, err) - require.NotNil(t, formattedCode) - require.Empty(t, formattedCode.Body) - require.NotNil(t, formattedCode.Error) - assert.Equal(t, 3, formattedCode.Error.Line) - assert.Equal(t, 15, formattedCode.Error.Column) - assert.Equal(t, "expected '}', found 'EOF'", formattedCode.Error.Msg) - }) - - t.Run("InvalidBody", func(t *testing.T) { - ctrl, _, err := newTestController(t) - require.NoError(t, err) - - _, err = ctrl.FmtCode(context.Background(), &FmtCodeParams{ - Body: "-- prog.go --\n-- prog.go --", - }) - require.Error(t, err) - assert.EqualError(t, err, `duplicate file name "prog.go"`) - }) -} - -func TestControllerGetUpInfo(t *testing.T) { - t.Run("Normal", func(t *testing.T) { - ctrl, _, err := newTestController(t) - require.NoError(t, err) - - upInfo, err := ctrl.GetUpInfo(context.Background()) - require.NoError(t, err) - require.NotNil(t, upInfo) - assert.NotEmpty(t, upInfo.Token) - assert.NotZero(t, upInfo.Expires) - assert.NotZero(t, upInfo.MaxSize) - assert.Equal(t, ctrl.kodo.bucket, upInfo.Bucket) - assert.Equal(t, ctrl.kodo.bucketRegion, upInfo.Region) - }) -} - -func TestMakeFileURLsParamsValidate(t *testing.T) { - t.Run("Normal", func(t *testing.T) { - params := &MakeFileURLsParams{ - Objects: []string{"kodo://builder/foo/bar"}, - } - ok, msg := params.Validate() - assert.True(t, ok) - assert.Empty(t, msg) - }) -} - -func TestControllerMakeFileURLs(t *testing.T) { - t.Run("Normal", func(t *testing.T) { - ctrl, _, err := newTestController(t) - require.NoError(t, err) - - fileURLs, err := ctrl.MakeFileURLs(context.Background(), &MakeFileURLsParams{ - Objects: []string{"kodo://builder/foo/bar"}, - }) - require.NoError(t, err) - require.NotNil(t, fileURLs) - assert.Len(t, fileURLs.ObjectURLs, 1) - }) - - t.Run("EmptyObjects", func(t *testing.T) { - ctrl, _, err := newTestController(t) - require.NoError(t, err) - - fileURLs, err := ctrl.MakeFileURLs(context.Background(), &MakeFileURLsParams{}) - require.NoError(t, err) - require.NotNil(t, fileURLs) - assert.Empty(t, fileURLs.ObjectURLs) - }) - - t.Run("InvalidObject", func(t *testing.T) { - ctrl, _, err := newTestController(t) - require.NoError(t, err) - - _, err = ctrl.MakeFileURLs(context.Background(), &MakeFileURLsParams{ - Objects: []string{"://invalid"}, - }) - require.Error(t, err) - assert.EqualError(t, err, `parse "://invalid": missing protocol scheme`) - }) - - t.Run("UnrecognizedObject", func(t *testing.T) { - ctrl, _, err := newTestController(t) - require.NoError(t, err) - - _, err = ctrl.MakeFileURLs(context.Background(), &MakeFileURLsParams{ - Objects: []string{"not-kodo://builder/foo/bar"}, - }) - require.Error(t, err) - assert.EqualError(t, err, "unrecognized object: not-kodo://builder/foo/bar") - }) - - t.Run("URLJoinPathError", func(t *testing.T) { - ctrl, _, err := newTestController(t) - require.NoError(t, err) - ctrl.kodo.baseUrl = "://invalid" - - _, err = ctrl.MakeFileURLs(context.Background(), &MakeFileURLsParams{ - Objects: []string{"kodo://builder/foo/bar"}, - }) - require.Error(t, err) - assert.EqualError(t, err, `parse "://invalid": missing protocol scheme`) - }) -} diff --git a/spx-backend/internal/model/asset.go b/spx-backend/internal/model/asset.go index b592fbfcf..4f6df8aae 100644 --- a/spx-backend/internal/model/asset.go +++ b/spx-backend/internal/model/asset.go @@ -1,58 +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"` - - // DisplayName is the name to display. - DisplayName string `db:"display_name" json:"displayName"` + Model - // Owner is the name of the asset owner. - Owner string `db:"owner" json:"owner"` + // OwnerID is the ID of the asset owner. + OwnerID int64 `gorm:"column:owner_id"` + Owner User `gorm:"foreignKey:OwnerID"` - // Category is the asset category. - Category string `db:"category" json:"category"` + // DisplayName is the display name. + DisplayName string `gorm:"column:display_name"` - // 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"` - // Files contains the asset's files. - Files FileCollection `db:"files" json:"files"` + // AssetType is the type of the asset. + AssetType AssetType `gorm:"column:asset_type"` - // FilesHash is the hash of the asset's files. - FilesHash string `db:"files_hash" json:"filesHash"` + // Files contains the file paths and their corresponding universal URLs + // associated with the asset. + Files FileCollection `gorm:"column:files"` - // Preview is the URL for the asset preview, e.g., a gif for a sprite. - Preview string `db:"preview" json:"preview"` + // FilesHash is the hash of the asset files. + FilesHash string `gorm:"column:files_hash"` - // 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"` + // IsPublic is the visibility status. + IsPublic IsPublic `gorm:"column:is_public"` // Status indicates if the asset is deleted. - Status Status `db:"status" json:"status"` + Status Status `gorm:"column:status"` } -// 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 @@ -63,53 +45,11 @@ 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 +// IsValid reports whether the asset type is valid. +func (at AssetType) IsValid() bool { + switch at { + case AssetTypeSprite, AssetTypeBackdrop, AssetTypeSound: + return true } - return AssetByID(ctx, db, id) -} - -// 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 - } - 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 false } 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..18a57b994 100644 --- a/spx-backend/internal/model/model.go +++ b/spx-backend/internal/model/model.go @@ -1,12 +1,47 @@ 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" ) +// Model is the base model for all models. +type Model struct { + ID int64 `gorm:"column:id;autoIncrement;primaryKey"` + CTime time.Time `gorm:"column:c_time;not null"` + UTime time.Time `gorm:"column:u_time;not null"` +} + +// 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 err := db.WithContext(ctx).AutoMigrate(models...); err != nil { + return nil, fmt.Errorf("failed to auto migrate models: %w", err) + } + return db, nil +} + // IsPublic indicates the visibility of an item. type IsPublic int @@ -15,6 +50,15 @@ const ( Public ) +// IsValid reports whether the value is valid. +func (ip IsPublic) IsValid() bool { + switch ip { + case Personal, Public: + return true + } + return false +} + // Status indicates the status of an item. type Status int @@ -22,3 +66,38 @@ const ( StatusDeleted Status = iota StatusNormal ) + +// IsValid reports whether the status is valid. +func (s Status) IsValid() bool { + switch s { + case StatusDeleted, StatusNormal: + return true + } + return false +} + +// 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/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/project.go b/spx-backend/internal/model/project.go index 2c9604365..acfd48a38 100644 --- a/spx-backend/internal/model/project.go +++ b/spx-backend/internal/model/project.go @@ -1,91 +1,91 @@ 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"` - - // CTime is the creation time. - CTime time.Time `db:"c_time" json:"cTime"` + Model - // UTime is the last update time. - UTime time.Time `db:"u_time" json:"uTime"` + // OwnerID is the ID of the project owner. + OwnerID int64 `gorm:"column:owner_id;index:idx_project_owner_id_name,unique"` + Owner User `gorm:"foreignKey:OwnerID"` - // Name is the project's name, unique for projects of the same owner. - Name string `db:"name" json:"name"` + // 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"` + RemixedFromRelease *ProjectRelease `gorm:"foreignKey:RemixedFromReleaseID"` - // Owner is the name of the project owner. - Owner string `db:"owner" json:"owner"` + // Name is the unique name. + Name string `gorm:"column:name;index:idx_project_owner_id_name,unique"` - // Version is the project version. - Version int `db:"version" json:"version"` + // Version is the version number. + Version int `gorm:"column:version"` - // Files contains the project's files. - Files FileCollection `db:"files" json:"files"` + // Files contains the file paths and their corresponding universal URLs + // associated with the project. + Files FileCollection `gorm:"column:files"` - // IsPublic indicates if the project is public. - IsPublic IsPublic `db:"is_public" json:"isPublic"` + // IsPublic is the visibility status. + IsPublic IsPublic `gorm:"column:is_public"` // Status indicates if the project is deleted. - Status Status `db:"status" json:"status"` -} + Status Status `gorm:"column:status"` -// TableProject is the table name of [Project] in database. -const TableProject = "project" + // Description is the brief description. + Description string `gorm:"column:description"` -// 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) -} + // Instructions is the instructions on how to interact with the project. + Instructions string `gorm:"column:instructions"` + + // Thumbnail is the URL of the thumbnail image. + Thumbnail string `gorm:"column:thumbnail"` + + // ViewCount is the number of times the project has been viewed. + ViewCount int64 `gorm:"column:view_count"` -// 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"` + + // ReleaseCount is the number of releases associated with the project. + ReleaseCount int64 `gorm:"column:release_count"` + + // RemixCount is the number of times the project has been remixed. + RemixCount int64 `gorm:"column:remix_count"` } -// 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) +// TableName implements [gorm.io/gorm/schema.Tabler]. +func (Project) TableName() string { + return "project" } -// AddProject adds a project. -func AddProject(ctx context.Context, db *sql.DB, p *Project) (*Project, error) { - logger := log.GetReqLogger(ctx) +// UserProjectRelationship is the model for user-project relationships. +type UserProjectRelationship struct { + Model - 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 - } + // UserID is the ID of the user involved in the relationship. + UserID int64 `gorm:"column:user_id;index:idx_user_project_relationship_user_id_project_id,unique"` - return Create(ctx, db, TableProject, p) -} + // ProjectID is the ID of the project involved in the relationship. + ProjectID int64 `gorm:"column:project_id;index:idx_user_project_relationship_user_id_project_id,unique"` + + // ViewCount is the number of times the user has viewed the project. + ViewCount int64 `gorm:"column:view_count"` + + // 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"` -// 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) + // 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"` } -// 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 (UserProjectRelationship) TableName() string { + return "user_project_relationship" } diff --git a/spx-backend/internal/model/project_release.go b/spx-backend/internal/model/project_release.go new file mode 100644 index 000000000..c60a31bc2 --- /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:idx_project_release_project_id_name,unique"` + Project Project `gorm:"foreignKey:ProjectID"` + + // Name is the unique name of the project release. + Name string `gorm:"column:name;index:idx_project_release_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"` +} + +// 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..89b436957 --- /dev/null +++ b/spx-backend/internal/model/user.go @@ -0,0 +1,56 @@ +package model + +import "database/sql" + +// User is the model for users. +type User struct { + Model + + // Username is the unique username. + Username string `gorm:"column:username;index:idx_user_username,unique"` + + // 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" +} + +// 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:idx_user_relationship_user_id_target_user_id,unique"` + + // TargetUserID is the ID of the target user involved in the relationship. + TargetUserID int64 `gorm:"column:target_user_id;index:idx_user_relationship_user_id_target_user_id,unique"` + + // 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"` +} + +// TableName implements [gorm.io/gorm/schema.Tabler]. +func (UserRelationship) TableName() string { + return "user_relationship" +}