From c524d33402c76bc4cccea2806f289e08a009baae Mon Sep 17 00:00:00 2001 From: fluzz Date: Tue, 30 May 2023 18:42:58 +0200 Subject: [PATCH 01/12] WIP: Add an 'updated_at' field to the EditIssueOption struct This field adds the possibility to set the update date when modifying an issue through the API. A 'NoAutoDate' in-memory field is added in the Issue struct. If the update_at field is set, NoAutoDate is set to true and the Issue's UpdatedUnix field is filled. That information is passed down to the functions that actually updates the database, which have been modified to not auto update dates if requested. A guard is added to the 'EditIssue' API call, to checks that the udpate_at date is between the issue's creation date and the current date (to avoid 'malicious' changes). It also limits the new feature to project's owners and admins. --- models/issues/comment.go | 5 +++++ models/issues/issue.go | 1 + models/issues/issue_update.go | 36 +++++++++++++++++++++++++--------- models/issues/issue_xref.go | 4 ++++ models/issues/milestone.go | 22 ++++++++++++++++----- modules/structs/issue.go | 2 ++ routers/api/v1/repo/issue.go | 27 +++++++++++++++++++++++++ services/issue/milestone.go | 20 +++++++++++++++---- templates/swagger/v1_json.tmpl | 5 +++++ 9 files changed, 104 insertions(+), 18 deletions(-) diff --git a/models/issues/comment.go b/models/issues/comment.go index e781931261..643956670d 100644 --- a/models/issues/comment.go +++ b/models/issues/comment.go @@ -823,6 +823,11 @@ func CreateComment(ctx context.Context, opts *CreateCommentOptions) (_ *Comment, IsForcePush: opts.IsForcePush, Invalidated: opts.Invalidated, } + if opts.Issue.NoAutoTime { + comment.CreatedUnix = opts.Issue.UpdatedUnix + comment.UpdatedUnix = opts.Issue.UpdatedUnix + e.NoAutoTime() + } if _, err = e.Insert(comment); err != nil { return nil, err } diff --git a/models/issues/issue.go b/models/issues/issue.go index f000f4c660..acd6eb15f9 100644 --- a/models/issues/issue.go +++ b/models/issues/issue.go @@ -125,6 +125,7 @@ type Issue struct { CreatedUnix timeutil.TimeStamp `xorm:"INDEX created"` UpdatedUnix timeutil.TimeStamp `xorm:"INDEX updated"` ClosedUnix timeutil.TimeStamp `xorm:"INDEX"` + NoAutoTime bool `xorm:"-"` Attachments []*repo_model.Attachment `xorm:"-"` Comments CommentList `xorm:"-"` diff --git a/models/issues/issue_update.go b/models/issues/issue_update.go index 9607b21a67..a9a79d3b19 100644 --- a/models/issues/issue_update.go +++ b/models/issues/issue_update.go @@ -27,7 +27,12 @@ import ( // UpdateIssueCols updates cols of issue func UpdateIssueCols(ctx context.Context, issue *Issue, cols ...string) error { - if _, err := db.GetEngine(ctx).ID(issue.ID).Cols(cols...).Update(issue); err != nil { + sess := db.GetEngine(ctx).ID(issue.ID) + if issue.NoAutoTime { + cols = append(cols, []string{"updated_unix"}...) + sess.NoAutoTime() + } + if _, err := sess.Cols(cols...).Update(issue); err != nil { return err } return nil @@ -71,7 +76,11 @@ func doChangeIssueStatus(ctx context.Context, issue *Issue, doer *user_model.Use } if issue.IsClosed { - issue.ClosedUnix = timeutil.TimeStampNow() + if issue.NoAutoTime { + issue.ClosedUnix = issue.UpdatedUnix + } else { + issue.ClosedUnix = timeutil.TimeStampNow() + } } else { issue.ClosedUnix = 0 } @@ -92,8 +101,14 @@ func doChangeIssueStatus(ctx context.Context, issue *Issue, doer *user_model.Use // Update issue count of milestone if issue.MilestoneID > 0 { - if err := UpdateMilestoneCounters(ctx, issue.MilestoneID); err != nil { - return nil, err + if issue.NoAutoTime { + if err := UpdateMilestoneCountersWithDate(ctx, issue.MilestoneID, issue.UpdatedUnix); err != nil { + return nil, err + } + } else { + if err := UpdateMilestoneCounters(ctx, issue.MilestoneID); err != nil { + return nil, err + } } } @@ -449,10 +464,13 @@ func UpdateIssueByAPI(issue *Issue, doer *user_model.User) (statusChangeComment return nil, false, err } - if _, err := db.GetEngine(ctx).ID(issue.ID).Cols( - "name", "content", "milestone_id", "priority", - "deadline_unix", "updated_unix", "is_locked"). - Update(issue); err != nil { + sess := db.GetEngine(ctx).ID(issue.ID) + cols := []string{"name", "content", "milestone_id", "priority", "deadline_unix", "is_locked"} + if issue.NoAutoTime { + cols = append(cols, "updated_unix") + sess.NoAutoTime() + } + if _, err := sess.Cols(cols...).Update(issue); err != nil { return nil, false, err } @@ -498,7 +516,7 @@ func UpdateIssueDeadline(issue *Issue, deadlineUnix timeutil.TimeStamp, doer *us defer committer.Close() // Update the deadline - if err = UpdateIssueCols(ctx, &Issue{ID: issue.ID, DeadlineUnix: deadlineUnix}, "deadline_unix"); err != nil { + if err = UpdateIssueCols(ctx, &Issue{ID: issue.ID, DeadlineUnix: deadlineUnix, NoAutoTime: issue.NoAutoTime, UpdatedUnix: issue.UpdatedUnix}, "deadline_unix"); err != nil { return err } diff --git a/models/issues/issue_xref.go b/models/issues/issue_xref.go index a1086f9e81..d580c75fb8 100644 --- a/models/issues/issue_xref.go +++ b/models/issues/issue_xref.go @@ -110,6 +110,10 @@ func (issue *Issue) createCrossReferences(stdCtx context.Context, ctx *crossRefe if ctx.OrigComment != nil { refCommentID = ctx.OrigComment.ID } + if ctx.OrigIssue.NoAutoTime { + xref.Issue.NoAutoTime = true + xref.Issue.UpdatedUnix = ctx.OrigIssue.UpdatedUnix + } opts := &CreateCommentOptions{ Type: ctx.Type, Doer: ctx.Doer, diff --git a/models/issues/milestone.go b/models/issues/milestone.go index 1418e0869d..9d30a0f871 100644 --- a/models/issues/milestone.go +++ b/models/issues/milestone.go @@ -188,10 +188,9 @@ func updateMilestone(ctx context.Context, m *Milestone) error { return UpdateMilestoneCounters(ctx, m.ID) } -// UpdateMilestoneCounters calculates NumIssues, NumClosesIssues and Completeness -func UpdateMilestoneCounters(ctx context.Context, id int64) error { +func updateMilestoneCounters(ctx context.Context, id int64, noAutoTime bool, updatedUnix timeutil.TimeStamp) error { e := db.GetEngine(ctx) - _, err := e.ID(id). + sess := e.ID(id). SetExpr("num_issues", builder.Select("count(*)").From("issue").Where( builder.Eq{"milestone_id": id}, )). @@ -200,8 +199,11 @@ func UpdateMilestoneCounters(ctx context.Context, id int64) error { "milestone_id": id, "is_closed": true, }, - )). - Update(&Milestone{}) + )) + if noAutoTime { + sess.SetExpr("updated_unix", updatedUnix).NoAutoTime() + } + _, err := sess.Update(&Milestone{}) if err != nil { return err } @@ -211,6 +213,16 @@ func UpdateMilestoneCounters(ctx context.Context, id int64) error { return err } +// UpdateMilestoneCounters calculates NumIssues, NumClosesIssues and Completeness +func UpdateMilestoneCounters(ctx context.Context, id int64) error { + return updateMilestoneCounters(ctx, id, false, 0) +} + +// UpdateMilestoneCountersWithDate calculates NumIssues, NumClosesIssues and Completeness and set the UpdatedUnix date +func UpdateMilestoneCountersWithDate(ctx context.Context, id int64, updatedUnix timeutil.TimeStamp) error { + return updateMilestoneCounters(ctx, id, true, updatedUnix) +} + // ChangeMilestoneStatusByRepoIDAndID changes a milestone open/closed status if the milestone ID is in the repo. func ChangeMilestoneStatusByRepoIDAndID(repoID, milestoneID int64, isClosed bool) error { ctx, committer, err := db.TxContext(db.DefaultContext) diff --git a/modules/structs/issue.go b/modules/structs/issue.go index 1aec5cc6b8..552496e652 100644 --- a/modules/structs/issue.go +++ b/modules/structs/issue.go @@ -110,6 +110,8 @@ type EditIssueOption struct { // swagger:strfmt date-time Deadline *time.Time `json:"due_date"` RemoveDeadline *bool `json:"unset_due_date"` + // swagger:strfmt date-time + Updated *time.Time `json:"updated_at"` } // EditDeadlineOption options for creating a deadline diff --git a/routers/api/v1/repo/issue.go b/routers/api/v1/repo/issue.go index a08fdf5940..bd756a3f11 100644 --- a/routers/api/v1/repo/issue.go +++ b/routers/api/v1/repo/issue.go @@ -774,6 +774,33 @@ func EditIssue(ctx *context.APIContext) { return } + // In order to be set a specific update time, the DB will be updated + // with NoAutoTime. The 'noAutoTime' bool will be propagated down to the + // DB update calls to apply autoupdate or not. + issue.NoAutoTime = false + if form.Updated != nil { + // Check if the poster is allowed to set an update date + perm, err := access_model.GetUserRepoPermission(ctx, issue.Repo, ctx.Doer) + if err != nil { + ctx.Status(http.StatusForbidden) + return + } + if !perm.IsAdmin() && !perm.IsOwner() { + ctx.Error(http.StatusUnauthorized, "EditIssue", "user needs to have admin or owner right") + return + } + + // A simple guard against potential inconsistent calls + updatedUnix := timeutil.TimeStamp(form.Updated.Unix()) + if updatedUnix < issue.CreatedUnix || updatedUnix > timeutil.TimeStampNow() { + ctx.Error(http.StatusForbidden, "EditIssue", "unallowed update date") + return + } + + issue.UpdatedUnix = updatedUnix + issue.NoAutoTime = true + } + oldTitle := issue.Title if len(form.Title) > 0 { issue.Title = form.Title diff --git a/services/issue/milestone.go b/services/issue/milestone.go index a9be8bd887..6de3ce2d8b 100644 --- a/services/issue/milestone.go +++ b/services/issue/milestone.go @@ -30,14 +30,26 @@ func changeMilestoneAssign(ctx context.Context, doer *user_model.User, issue *is } if oldMilestoneID > 0 { - if err := issues_model.UpdateMilestoneCounters(ctx, oldMilestoneID); err != nil { - return err + if issue.NoAutoTime { + if err := issues_model.UpdateMilestoneCountersWithDate(ctx, oldMilestoneID, issue.UpdatedUnix); err != nil { + return err + } + } else { + if err := issues_model.UpdateMilestoneCounters(ctx, oldMilestoneID); err != nil { + return err + } } } if issue.MilestoneID > 0 { - if err := issues_model.UpdateMilestoneCounters(ctx, issue.MilestoneID); err != nil { - return err + if issue.NoAutoTime { + if err := issues_model.UpdateMilestoneCountersWithDate(ctx, issue.MilestoneID, issue.UpdatedUnix); err != nil { + return err + } + } else { + if err := issues_model.UpdateMilestoneCounters(ctx, issue.MilestoneID); err != nil { + return err + } } } diff --git a/templates/swagger/v1_json.tmpl b/templates/swagger/v1_json.tmpl index 5e75f6f8b4..f60efa5172 100644 --- a/templates/swagger/v1_json.tmpl +++ b/templates/swagger/v1_json.tmpl @@ -18086,6 +18086,11 @@ "unset_due_date": { "type": "boolean", "x-go-name": "RemoveDeadline" + }, + "updated_at": { + "type": "string", + "format": "date-time", + "x-go-name": "Updated" } }, "x-go-package": "code.gitea.io/gitea/modules/structs" From f061caa6555e0c9e922ee1e73dd2e4337360e9fe Mon Sep 17 00:00:00 2001 From: fluzz Date: Tue, 30 May 2023 18:53:56 +0200 Subject: [PATCH 02/12] Add a SetIssueUpdateDate() function in services/issue.go That function is used by some API calls to set the NoAutoDate and UpdatedUnix fields of an Issue if an updated_at date is provided. --- routers/api/v1/repo/issue.go | 29 ++++----------------------- services/issue/issue.go | 39 ++++++++++++++++++++++++++++++++++++ 2 files changed, 43 insertions(+), 25 deletions(-) diff --git a/routers/api/v1/repo/issue.go b/routers/api/v1/repo/issue.go index bd756a3f11..9001f13af2 100644 --- a/routers/api/v1/repo/issue.go +++ b/routers/api/v1/repo/issue.go @@ -774,31 +774,10 @@ func EditIssue(ctx *context.APIContext) { return } - // In order to be set a specific update time, the DB will be updated - // with NoAutoTime. The 'noAutoTime' bool will be propagated down to the - // DB update calls to apply autoupdate or not. - issue.NoAutoTime = false - if form.Updated != nil { - // Check if the poster is allowed to set an update date - perm, err := access_model.GetUserRepoPermission(ctx, issue.Repo, ctx.Doer) - if err != nil { - ctx.Status(http.StatusForbidden) - return - } - if !perm.IsAdmin() && !perm.IsOwner() { - ctx.Error(http.StatusUnauthorized, "EditIssue", "user needs to have admin or owner right") - return - } - - // A simple guard against potential inconsistent calls - updatedUnix := timeutil.TimeStamp(form.Updated.Unix()) - if updatedUnix < issue.CreatedUnix || updatedUnix > timeutil.TimeStampNow() { - ctx.Error(http.StatusForbidden, "EditIssue", "unallowed update date") - return - } - - issue.UpdatedUnix = updatedUnix - issue.NoAutoTime = true + err = issue_service.SetIssueUpdateDate(ctx, issue, form.Updated, ctx.Doer) + if err != nil { + ctx.Error(http.StatusForbidden, "SetIssueUpdateDate", err) + return } oldTitle := issue.Title diff --git a/services/issue/issue.go b/services/issue/issue.go index 35409589ef..2c1350e7fd 100644 --- a/services/issue/issue.go +++ b/services/issue/issue.go @@ -6,6 +6,7 @@ package issue import ( "context" "fmt" + "time" activities_model "code.gitea.io/gitea/models/activities" "code.gitea.io/gitea/models/db" @@ -18,6 +19,7 @@ import ( "code.gitea.io/gitea/modules/git" "code.gitea.io/gitea/modules/notification" "code.gitea.io/gitea/modules/storage" + "code.gitea.io/gitea/modules/timeutil" ) // NewIssue creates new issue with labels for repository. @@ -304,3 +306,40 @@ func deleteIssue(ctx context.Context, issue *issues_model.Issue) error { return committer.Commit() } + +// Set the UpdatedUnix date and the NoAutoTime field of an Issue if a non +// nil 'updated' time is provided +// +// In order to set a specific update time, the DB will be updated with +// NoAutoTime(). A 'NoAutoTime' boolean field in the Issue struct is used to +// propagate down to the DB update calls the will to apply autoupdate or not. +func SetIssueUpdateDate(ctx context.Context, issue *issues_model.Issue, updated *time.Time, doer *user_model.User) error { + issue.NoAutoTime = false + if updated == nil { + return nil + } + + if err := issue.LoadRepo(ctx); err != nil { + return err + } + + // Check if the poster is allowed to set an update date + perm, err := access_model.GetUserRepoPermission(ctx, issue.Repo, doer) + if err != nil { + return err + } + if !perm.IsAdmin() && !perm.IsOwner() { + return fmt.Errorf("user needs to have admin or owner right") + } + + // A simple guard against potential inconsistent calls + updatedUnix := timeutil.TimeStamp(updated.Unix()) + if updatedUnix < issue.CreatedUnix || updatedUnix > timeutil.TimeStampNow() { + return fmt.Errorf("unallowed update date") + } + + issue.UpdatedUnix = updatedUnix + issue.NoAutoTime = true + + return nil +} From ea36cf80f58f0ab20c565a8f5d063b90fd741f97 Mon Sep 17 00:00:00 2001 From: fluzz Date: Tue, 6 Jun 2023 18:36:19 +0200 Subject: [PATCH 03/12] Add an updated_at field to the API calls related to Issue's Labels. The update date is applied to the issue's comment created to inform about the modification of the issue's labels. --- modules/structs/issue_label.go | 12 ++++++++++++ routers/api/v1/api.go | 4 ++-- routers/api/v1/repo/issue_label.go | 26 +++++++++++++++++++++++++ routers/api/v1/swagger/options.go | 3 +++ templates/swagger/v1_json.tmpl | 31 ++++++++++++++++++++++++++++++ 5 files changed, 74 insertions(+), 2 deletions(-) diff --git a/modules/structs/issue_label.go b/modules/structs/issue_label.go index bf68726d79..b64e375961 100644 --- a/modules/structs/issue_label.go +++ b/modules/structs/issue_label.go @@ -4,6 +4,10 @@ package structs +import ( + "time" +) + // Label a label to an issue or a pr // swagger:model type Label struct { @@ -45,10 +49,18 @@ type EditLabelOption struct { IsArchived *bool `json:"is_archived"` } +// DeleteLabelOption options for deleting a label +type DeleteLabelsOption struct { + // swagger:strfmt date-time + Updated *time.Time `json:"updated_at"` +} + // IssueLabelsOption a collection of labels type IssueLabelsOption struct { // list of label IDs Labels []int64 `json:"labels"` + // swagger:strfmt date-time + Updated *time.Time `json:"updated_at"` } // LabelTemplate info of a Label template diff --git a/routers/api/v1/api.go b/routers/api/v1/api.go index 9613bd610d..48ad691cc4 100644 --- a/routers/api/v1/api.go +++ b/routers/api/v1/api.go @@ -1201,8 +1201,8 @@ func Routes() *web.Route { m.Combo("").Get(repo.ListIssueLabels). Post(reqToken(), bind(api.IssueLabelsOption{}), repo.AddIssueLabels). Put(reqToken(), bind(api.IssueLabelsOption{}), repo.ReplaceIssueLabels). - Delete(reqToken(), repo.ClearIssueLabels) - m.Delete("/{id}", reqToken(), repo.DeleteIssueLabel) + Delete(reqToken(), bind(api.DeleteLabelsOption{}), repo.ClearIssueLabels) + m.Delete("/{id}", reqToken(), bind(api.DeleteLabelsOption{}), repo.DeleteIssueLabel) }) m.Group("/times", func() { m.Combo(""). diff --git a/routers/api/v1/repo/issue_label.go b/routers/api/v1/repo/issue_label.go index a2814a03db..f54af94a2d 100644 --- a/routers/api/v1/repo/issue_label.go +++ b/routers/api/v1/repo/issue_label.go @@ -149,6 +149,10 @@ func DeleteIssueLabel(ctx *context.APIContext) { // type: integer // format: int64 // required: true + // - name: body + // in: body + // schema: + // "$ref": "#/definitions/DeleteLabelsOption" // responses: // "204": // "$ref": "#/responses/empty" @@ -156,6 +160,7 @@ func DeleteIssueLabel(ctx *context.APIContext) { // "$ref": "#/responses/forbidden" // "422": // "$ref": "#/responses/validationError" + form := web.GetForm(ctx).(*api.DeleteLabelsOption) issue, err := issues_model.GetIssueByIndex(ctx, ctx.Repo.Repository.ID, ctx.ParamsInt64(":index")) if err != nil { @@ -172,6 +177,11 @@ func DeleteIssueLabel(ctx *context.APIContext) { return } + if err := issue_service.SetIssueUpdateDate(ctx, issue, form.Updated, ctx.Doer); err != nil { + ctx.Error(http.StatusForbidden, "SetIssueUpdateDate", err) + return + } + label, err := issues_model.GetLabelByID(ctx, ctx.ParamsInt64(":id")) if err != nil { if issues_model.IsErrLabelNotExist(err) { @@ -269,11 +279,16 @@ func ClearIssueLabels(ctx *context.APIContext) { // type: integer // format: int64 // required: true + // - name: body + // in: body + // schema: + // "$ref": "#/definitions/DeleteLabelsOption" // responses: // "204": // "$ref": "#/responses/empty" // "403": // "$ref": "#/responses/forbidden" + form := web.GetForm(ctx).(*api.DeleteLabelsOption) issue, err := issues_model.GetIssueByIndex(ctx, ctx.Repo.Repository.ID, ctx.ParamsInt64(":index")) if err != nil { @@ -290,6 +305,11 @@ func ClearIssueLabels(ctx *context.APIContext) { return } + if err := issue_service.SetIssueUpdateDate(ctx, issue, form.Updated, ctx.Doer); err != nil { + ctx.Error(http.StatusForbidden, "SetIssueUpdateDate", err) + return + } + if err := issue_service.ClearLabels(issue, ctx.Doer); err != nil { ctx.Error(http.StatusInternalServerError, "ClearLabels", err) return @@ -320,5 +340,11 @@ func prepareForReplaceOrAdd(ctx *context.APIContext, form api.IssueLabelsOption) return nil, nil, nil } + err = issue_service.SetIssueUpdateDate(ctx, issue, form.Updated, ctx.Doer) + if err != nil { + ctx.Error(http.StatusForbidden, "SetIssueUpdateDate", err) + return nil, nil, err + } + return issue, labels, err } diff --git a/routers/api/v1/swagger/options.go b/routers/api/v1/swagger/options.go index 8e7e6ec3df..4a2213cd36 100644 --- a/routers/api/v1/swagger/options.go +++ b/routers/api/v1/swagger/options.go @@ -47,6 +47,9 @@ type swaggerParameterBodies struct { // in:body IssueLabelsOption api.IssueLabelsOption + // in:body + DeleteLabelsOption api.DeleteLabelsOption + // in:body CreateKeyOption api.CreateKeyOption diff --git a/templates/swagger/v1_json.tmpl b/templates/swagger/v1_json.tmpl index f60efa5172..4b5a52fb39 100644 --- a/templates/swagger/v1_json.tmpl +++ b/templates/swagger/v1_json.tmpl @@ -7651,6 +7651,13 @@ "name": "index", "in": "path", "required": true + }, + { + "name": "body", + "in": "body", + "schema": { + "$ref": "#/definitions/DeleteLabelsOption" + } } ], "responses": { @@ -7703,6 +7710,13 @@ "name": "id", "in": "path", "required": true + }, + { + "name": "body", + "in": "body", + "schema": { + "$ref": "#/definitions/DeleteLabelsOption" + } } ], "responses": { @@ -17784,6 +17798,18 @@ }, "x-go-package": "code.gitea.io/gitea/modules/structs" }, + "DeleteLabelsOption": { + "description": "DeleteLabelOption options for deleting a label", + "type": "object", + "properties": { + "updated_at": { + "type": "string", + "format": "date-time", + "x-go-name": "Updated" + } + }, + "x-go-package": "code.gitea.io/gitea/modules/structs" + }, "DeployKey": { "description": "DeployKey a deploy key", "type": "object", @@ -19505,6 +19531,11 @@ "format": "int64" }, "x-go-name": "Labels" + }, + "updated_at": { + "type": "string", + "format": "date-time", + "x-go-name": "Updated" } }, "x-go-package": "code.gitea.io/gitea/modules/structs" From 96150971ca31b97e97e84d5f5eb95a177cc44e2e Mon Sep 17 00:00:00 2001 From: fluzz Date: Mon, 5 Jun 2023 19:08:52 +0200 Subject: [PATCH 04/12] Add an updated_at field to the API call for issue's attachment creation The update date is applied to the issue's comment created to inform about the modification of the issue's content, and is set as the asset creation date. --- models/issues/issue_update.go | 6 ++++- models/repo/attachment.go | 1 + routers/api/v1/repo/issue_attachment.go | 30 +++++++++++++++++++++---- services/attachment/attachment.go | 7 +++++- templates/swagger/v1_json.tmpl | 7 ++++++ 5 files changed, 45 insertions(+), 6 deletions(-) diff --git a/models/issues/issue_update.go b/models/issues/issue_update.go index a9a79d3b19..f7f7518272 100644 --- a/models/issues/issue_update.go +++ b/models/issues/issue_update.go @@ -274,8 +274,12 @@ func ChangeIssueContent(issue *Issue, doer *user_model.User, content string) (er return fmt.Errorf("UpdateIssueCols: %w", err) } + historyDate := timeutil.TimeStampNow() + if issue.NoAutoTime { + historyDate = issue.UpdatedUnix + } if err = SaveIssueContentHistory(ctx, doer.ID, issue.ID, 0, - timeutil.TimeStampNow(), issue.Content, false); err != nil { + historyDate, issue.Content, false); err != nil { return fmt.Errorf("SaveIssueContentHistory: %w", err) } diff --git a/models/repo/attachment.go b/models/repo/attachment.go index df3b9cd213..5e5a6ca0f8 100644 --- a/models/repo/attachment.go +++ b/models/repo/attachment.go @@ -28,6 +28,7 @@ type Attachment struct { Name string DownloadCount int64 `xorm:"DEFAULT 0"` Size int64 `xorm:"DEFAULT 0"` + NoAutoTime bool `xorm:"-"` CreatedUnix timeutil.TimeStamp `xorm:"created"` CustomDownloadURL string `xorm:"-"` } diff --git a/routers/api/v1/repo/issue_attachment.go b/routers/api/v1/repo/issue_attachment.go index ad83c206d9..4e0949206e 100644 --- a/routers/api/v1/repo/issue_attachment.go +++ b/routers/api/v1/repo/issue_attachment.go @@ -5,6 +5,7 @@ package repo import ( "net/http" + "time" issues_model "code.gitea.io/gitea/models/issues" repo_model "code.gitea.io/gitea/models/repo" @@ -141,6 +142,11 @@ func CreateIssueAttachment(ctx *context.APIContext) { // description: name of the attachment // type: string // required: false + // - name: updated_at + // in: query + // description: time of the attachment's creation. This is a timestamp in RFC 3339 format + // type: string + // format: date-time // - name: attachment // in: formData // description: attachment to upload @@ -163,6 +169,20 @@ func CreateIssueAttachment(ctx *context.APIContext) { return } + updatedAt := ctx.Req.FormValue("updated_at") + if len(updatedAt) != 0 { + updated, err := time.Parse(time.RFC3339, updatedAt) + if err != nil { + ctx.Error(http.StatusInternalServerError, "time.Parse", err) + return + } + err = issue_service.SetIssueUpdateDate(ctx, issue, &updated, ctx.Doer) + if err != nil { + ctx.Error(http.StatusForbidden, "SetIssueUpdateDate", err) + return + } + } + // Get uploaded file from request file, header, err := ctx.Req.FormFile("attachment") if err != nil { @@ -177,10 +197,12 @@ func CreateIssueAttachment(ctx *context.APIContext) { } attachment, err := attachment.UploadAttachment(file, setting.Attachment.AllowedTypes, header.Size, &repo_model.Attachment{ - Name: filename, - UploaderID: ctx.Doer.ID, - RepoID: ctx.Repo.Repository.ID, - IssueID: issue.ID, + Name: filename, + UploaderID: ctx.Doer.ID, + RepoID: ctx.Repo.Repository.ID, + IssueID: issue.ID, + NoAutoTime: issue.NoAutoTime, + CreatedUnix: issue.UpdatedUnix, }) if err != nil { ctx.Error(http.StatusInternalServerError, "UploadAttachment", err) diff --git a/services/attachment/attachment.go b/services/attachment/attachment.go index 3e7df0cee0..4482e7deed 100644 --- a/services/attachment/attachment.go +++ b/services/attachment/attachment.go @@ -32,7 +32,12 @@ func NewAttachment(attach *repo_model.Attachment, file io.Reader, size int64) (* } attach.Size = size - return db.Insert(ctx, attach) + eng := db.GetEngine(ctx) + if attach.NoAutoTime { + eng.NoAutoTime() + } + _, err = eng.Insert(attach) + return err }) return attach, err diff --git a/templates/swagger/v1_json.tmpl b/templates/swagger/v1_json.tmpl index 4b5a52fb39..e012c22a74 100644 --- a/templates/swagger/v1_json.tmpl +++ b/templates/swagger/v1_json.tmpl @@ -6718,6 +6718,13 @@ "name": "name", "in": "query" }, + { + "type": "string", + "format": "date-time", + "description": "time of the attachment's creation. This is a timestamp in RFC 3339 format", + "name": "updated_at", + "in": "query" + }, { "type": "file", "description": "attachment to upload", From 4926a5d7a28581003545256632213bf4136b193d Mon Sep 17 00:00:00 2001 From: fluzz Date: Wed, 7 Jun 2023 18:53:04 +0200 Subject: [PATCH 05/12] Checking Issue changes, with and without providing an updated_at date Those unit tests are added: - TestAPIEditIssueWithAutoDate - TestAPIEditIssueWithNoAutoDate - TestAPIAddIssueLabelsWithAutoDate - TestAPIAddIssueLabelsWithNoAutoDate - TestAPICreateIssueAttachmentWithAutoDate - TestAPICreateIssueAttachmentWithNoAutoDate --- .../integration/api_issue_attachment_test.go | 86 +++++++++++++++++++ tests/integration/api_issue_label_test.go | 54 ++++++++++++ tests/integration/api_issue_test.go | 57 ++++++++++++ 3 files changed, 197 insertions(+) diff --git a/tests/integration/api_issue_attachment_test.go b/tests/integration/api_issue_attachment_test.go index 3b43ba2c41..ba72df2400 100644 --- a/tests/integration/api_issue_attachment_test.go +++ b/tests/integration/api_issue_attachment_test.go @@ -11,6 +11,7 @@ import ( "mime/multipart" "net/http" "testing" + "time" auth_model "code.gitea.io/gitea/models/auth" issues_model "code.gitea.io/gitea/models/issues" @@ -100,6 +101,91 @@ func TestAPICreateIssueAttachment(t *testing.T) { unittest.AssertExistsAndLoadBean(t, &repo_model.Attachment{ID: apiAttachment.ID, IssueID: issue.ID}) } +func TestAPICreateIssueAttachmentWithAutoDate(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1}) + issue := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{RepoID: repo.ID}) + repoOwner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID}) + + session := loginUser(t, repoOwner.Name) + token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteIssue) + + filename := "image.png" + buff := generateImg() + body := &bytes.Buffer{} + + // Setup multi-part + writer := multipart.NewWriter(body) + part, err := writer.CreateFormFile("attachment", filename) + assert.NoError(t, err) + _, err = io.Copy(part, &buff) + assert.NoError(t, err) + err = writer.Close() + assert.NoError(t, err) + + urlStr := fmt.Sprintf("/api/v1/repos/%s/%s/issues/%d/assets?token=%s", + repoOwner.Name, repo.Name, issue.Index, token) + + req := NewRequestWithBody(t, "POST", urlStr, body) + req.Header.Add("Content-Type", writer.FormDataContentType()) + resp := session.MakeRequest(t, req, http.StatusCreated) + + apiAttachment := new(api.Attachment) + DecodeJSON(t, resp, &apiAttachment) + + unittest.AssertExistsAndLoadBean(t, &repo_model.Attachment{ID: apiAttachment.ID, IssueID: issue.ID}) + // the execution of the API call supposedly lasted less than one minute + updatedSince := time.Since(apiAttachment.Created) + assert.LessOrEqual(t, updatedSince, time.Minute) + + issueAfter := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: issue.Index}) + updatedSince = time.Since(issueAfter.UpdatedUnix.AsTime()) + assert.LessOrEqual(t, updatedSince, time.Minute) +} + +func TestAPICreateIssueAttachmentWithNoAutoDate(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1}) + issue := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{RepoID: repo.ID}) + repoOwner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID}) + + session := loginUser(t, repoOwner.Name) + token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteIssue) + + filename := "image.png" + buff := generateImg() + body := &bytes.Buffer{} + + // Setup multi-part + writer := multipart.NewWriter(body) + part, err := writer.CreateFormFile("attachment", filename) + assert.NoError(t, err) + _, err = io.Copy(part, &buff) + assert.NoError(t, err) + err = writer.Close() + assert.NoError(t, err) + + updatedAt := time.Now().Add(-time.Hour).Truncate(time.Second) + urlStr := fmt.Sprintf("/api/v1/repos/%s/%s/issues/%d/assets?token=%s&updated_at=%s", + repoOwner.Name, repo.Name, issue.Index, token, updatedAt.UTC().Format(time.RFC3339)) + + req := NewRequestWithBody(t, "POST", urlStr, body) + req.Header.Add("Content-Type", writer.FormDataContentType()) + resp := session.MakeRequest(t, req, http.StatusCreated) + + apiAttachment := new(api.Attachment) + DecodeJSON(t, resp, &apiAttachment) + + // dates will be converted into the same tz, in order to compare them + utcTZ, _ := time.LoadLocation("UTC") + unittest.AssertExistsAndLoadBean(t, &repo_model.Attachment{ID: apiAttachment.ID, IssueID: issue.ID}) + assert.Equal(t, updatedAt.In(utcTZ), apiAttachment.Created.In(utcTZ)) + issueAfter := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: issue.ID}) + assert.Equal(t, updatedAt.In(utcTZ), issueAfter.UpdatedUnix.AsTime().In(utcTZ)) +} + func TestAPIEditIssueAttachment(t *testing.T) { defer tests.PrepareTestEnv(t)() diff --git a/tests/integration/api_issue_label_test.go b/tests/integration/api_issue_label_test.go index d2d8af102b..3c2bed6be0 100644 --- a/tests/integration/api_issue_label_test.go +++ b/tests/integration/api_issue_label_test.go @@ -8,6 +8,7 @@ import ( "net/http" "strings" "testing" + "time" auth_model "code.gitea.io/gitea/models/auth" issues_model "code.gitea.io/gitea/models/issues" @@ -111,6 +112,59 @@ func TestAPIAddIssueLabels(t *testing.T) { unittest.AssertExistsAndLoadBean(t, &issues_model.IssueLabel{IssueID: issue.ID, LabelID: 2}) } +func TestAPIAddIssueLabelsWithAutoDate(t *testing.T) { + assert.NoError(t, unittest.LoadFixtures()) + + repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1}) + issue := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{RepoID: repo.ID}) + _ = unittest.AssertExistsAndLoadBean(t, &issues_model.Label{RepoID: repo.ID, ID: 2}) + owner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID}) + + session := loginUser(t, owner.Name) + token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteIssue) + + urlStr := fmt.Sprintf("/api/v1/repos/%s/%s/issues/%d/labels?token=%s", + repo.OwnerName, repo.Name, issue.Index, token) + req := NewRequestWithJSON(t, "POST", urlStr, &api.IssueLabelsOption{ + Labels: []int64{1, 2}, + }) + resp := MakeRequest(t, req, http.StatusOK) + var apiLabels []*api.Label + DecodeJSON(t, resp, &apiLabels) + + issueAfter := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: issue.Index}) + // the execution of the API call supposedly lasted less than one minute + updatedSince := time.Since(issueAfter.UpdatedUnix.AsTime()) + assert.LessOrEqual(t, updatedSince, time.Minute) +} + +func TestAPIAddIssueLabelsWithNoAutoDate(t *testing.T) { + assert.NoError(t, unittest.LoadFixtures()) + + repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1}) + issue := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{RepoID: repo.ID}) + _ = unittest.AssertExistsAndLoadBean(t, &issues_model.Label{RepoID: repo.ID, ID: 2}) + owner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID}) + + session := loginUser(t, owner.Name) + token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteIssue) + updatedAt := time.Now().Add(-time.Hour).Truncate(time.Second) + + urlStr := fmt.Sprintf("/api/v1/repos/%s/%s/issues/%d/labels?token=%s", + repo.OwnerName, repo.Name, issue.Index, token) + req := NewRequestWithJSON(t, "POST", urlStr, &api.IssueLabelsOption{ + Labels: []int64{1, 2}, + Updated: &updatedAt, + }) + resp := MakeRequest(t, req, http.StatusOK) + var apiLabels []*api.Label + DecodeJSON(t, resp, &apiLabels) + // dates will be converted into the same tz, in order to compare them + utcTZ, _ := time.LoadLocation("UTC") + issueAfter := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: issue.Index}) + assert.Equal(t, updatedAt.In(utcTZ), issueAfter.UpdatedUnix.AsTime().In(utcTZ)) +} + func TestAPIReplaceIssueLabels(t *testing.T) { assert.NoError(t, unittest.LoadFixtures()) diff --git a/tests/integration/api_issue_test.go b/tests/integration/api_issue_test.go index 5f4c1e6a47..ac26caf006 100644 --- a/tests/integration/api_issue_test.go +++ b/tests/integration/api_issue_test.go @@ -213,6 +213,63 @@ func TestAPIEditIssue(t *testing.T) { assert.Equal(t, title, issueAfter.Title) } +func TestAPIEditIssueWithAutoDate(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + issueBefore := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: 10}) + repoBefore := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: issueBefore.RepoID}) + owner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repoBefore.OwnerID}) + assert.NoError(t, issueBefore.LoadAttributes(db.DefaultContext)) + + session := loginUser(t, owner.Name) + token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteIssue) + + body := "new content!" + urlStr := fmt.Sprintf("/api/v1/repos/%s/%s/issues/%d?token=%s", owner.Name, repoBefore.Name, issueBefore.Index, token) + req := NewRequestWithJSON(t, "PATCH", urlStr, api.EditIssueOption{ + Body: &body, + }) + resp := MakeRequest(t, req, http.StatusCreated) + var apiIssue api.Issue + DecodeJSON(t, resp, &apiIssue) + + // the execution of the API call supposedly lasted less than one minute + updatedSince := time.Since(apiIssue.Updated) + assert.LessOrEqual(t, updatedSince, time.Minute) + + issueAfter := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: 10}) + updatedSince = time.Since(issueAfter.UpdatedUnix.AsTime()) + assert.LessOrEqual(t, updatedSince, time.Minute) +} + +func TestAPIEditIssueWithNoAutoDate(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + issueBefore := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: 10}) + repoBefore := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: issueBefore.RepoID}) + owner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repoBefore.OwnerID}) + assert.NoError(t, issueBefore.LoadAttributes(db.DefaultContext)) + + session := loginUser(t, owner.Name) + token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteIssue) + + body := "new content, with updated time" + updatedAt := time.Now().Add(-time.Hour).Truncate(time.Second) + urlStr := fmt.Sprintf("/api/v1/repos/%s/%s/issues/%d?token=%s", owner.Name, repoBefore.Name, issueBefore.Index, token) + req := NewRequestWithJSON(t, "PATCH", urlStr, api.EditIssueOption{ + Body: &body, + Updated: &updatedAt, + }) + resp := MakeRequest(t, req, http.StatusCreated) + var apiIssue api.Issue + DecodeJSON(t, resp, &apiIssue) + // dates will be converted into the same tz, in order to compare them + utcTZ, _ := time.LoadLocation("UTC") + assert.Equal(t, updatedAt.In(utcTZ), apiIssue.Updated.In(utcTZ)) + issueAfter := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: 10}) + assert.Equal(t, updatedAt.In(utcTZ), issueAfter.UpdatedUnix.AsTime().In(utcTZ)) +} + func TestAPISearchIssues(t *testing.T) { defer tests.PrepareTestEnv(t)() From 76c8faecdc6cba48ca4fe07d1a916d1f1a4b37b4 Mon Sep 17 00:00:00 2001 From: fluzz Date: Thu, 15 Jun 2023 18:50:20 +0200 Subject: [PATCH 06/12] Add an updated_at field to the API call for issue's comment creation The update date is used as the comment creation date, and is applied to the issue as the update creation date. --- modules/structs/issue_comment.go | 2 ++ routers/api/v1/repo/issue_comment.go | 6 ++++++ templates/swagger/v1_json.tmpl | 5 +++++ 3 files changed, 13 insertions(+) diff --git a/modules/structs/issue_comment.go b/modules/structs/issue_comment.go index 9e8f5c4bf3..8961b70ad3 100644 --- a/modules/structs/issue_comment.go +++ b/modules/structs/issue_comment.go @@ -28,6 +28,8 @@ type Comment struct { type CreateIssueCommentOption struct { // required:true Body string `json:"body" binding:"Required"` + // swagger:strfmt date-time + Updated *time.Time `json:"updated_at"` } // EditIssueCommentOption options for editing a comment diff --git a/routers/api/v1/repo/issue_comment.go b/routers/api/v1/repo/issue_comment.go index 1f8e16147f..6eed71fc74 100644 --- a/routers/api/v1/repo/issue_comment.go +++ b/routers/api/v1/repo/issue_comment.go @@ -362,6 +362,12 @@ func CreateIssueComment(ctx *context.APIContext) { return } + err = issue_service.SetIssueUpdateDate(ctx, issue, form.Updated, ctx.Doer) + if err != nil { + ctx.Error(http.StatusForbidden, "SetIssueUpdateDate", err) + return + } + comment, err := issue_service.CreateIssueComment(ctx, ctx.Doer, ctx.Repo.Repository, issue, form.Body, nil) if err != nil { ctx.Error(http.StatusInternalServerError, "CreateIssueComment", err) diff --git a/templates/swagger/v1_json.tmpl b/templates/swagger/v1_json.tmpl index e012c22a74..3b9d49dd92 100644 --- a/templates/swagger/v1_json.tmpl +++ b/templates/swagger/v1_json.tmpl @@ -17047,6 +17047,11 @@ "body": { "type": "string", "x-go-name": "Body" + }, + "updated_at": { + "type": "string", + "format": "date-time", + "x-go-name": "Updated" } }, "x-go-package": "code.gitea.io/gitea/modules/structs" From cf787ad7fdb8e6273fdc35d7b5cc164b400207e9 Mon Sep 17 00:00:00 2001 From: fluzz Date: Thu, 20 Jul 2023 11:35:10 +0200 Subject: [PATCH 07/12] Add an updated_at field to the API call for issue's comment edition The update date is used as the comment update date, and is applied to the issue as an update date. --- models/issues/comment.go | 9 ++++++--- modules/structs/issue_comment.go | 2 ++ routers/api/v1/repo/issue_comment.go | 11 +++++++++++ services/issue/comments.go | 6 +++++- templates/swagger/v1_json.tmpl | 5 +++++ 5 files changed, 29 insertions(+), 4 deletions(-) diff --git a/models/issues/comment.go b/models/issues/comment.go index 643956670d..0b46f40589 100644 --- a/models/issues/comment.go +++ b/models/issues/comment.go @@ -1106,9 +1106,12 @@ func UpdateComment(c *Comment, doer *user_model.User) error { return err } defer committer.Close() - sess := db.GetEngine(ctx) - - if _, err := sess.ID(c.ID).AllCols().Update(c); err != nil { + sess := db.GetEngine(ctx).ID(c.ID).AllCols() + if c.Issue.NoAutoTime { + c.UpdatedUnix = c.Issue.UpdatedUnix + sess = sess.NoAutoTime() + } + if _, err := sess.Update(c); err != nil { return err } if err := c.LoadIssue(ctx); err != nil { diff --git a/modules/structs/issue_comment.go b/modules/structs/issue_comment.go index 8961b70ad3..9ecb4a1789 100644 --- a/modules/structs/issue_comment.go +++ b/modules/structs/issue_comment.go @@ -36,6 +36,8 @@ type CreateIssueCommentOption struct { type EditIssueCommentOption struct { // required: true Body string `json:"body" binding:"Required"` + // swagger:strfmt date-time + Updated *time.Time `json:"updated_at"` } // TimelineComment represents a timeline comment (comment of any type) on a commit or issue diff --git a/routers/api/v1/repo/issue_comment.go b/routers/api/v1/repo/issue_comment.go index 6eed71fc74..a73f5c7a94 100644 --- a/routers/api/v1/repo/issue_comment.go +++ b/routers/api/v1/repo/issue_comment.go @@ -560,6 +560,17 @@ func editIssueComment(ctx *context.APIContext, form api.EditIssueCommentOption) return } + err = comment.LoadIssue(ctx) + if err != nil { + ctx.Error(http.StatusInternalServerError, "LoadIssue", err) + return + } + err = issue_service.SetIssueUpdateDate(ctx, comment.Issue, form.Updated, ctx.Doer) + if err != nil { + ctx.Error(http.StatusForbidden, "SetIssueUpdateDate", err) + return + } + oldContent := comment.Content comment.Content = form.Body if err := issue_service.UpdateComment(ctx, comment, ctx.Doer, oldContent); err != nil { diff --git a/services/issue/comments.go b/services/issue/comments.go index 2c5ef0f5dc..c89031a1d8 100644 --- a/services/issue/comments.go +++ b/services/issue/comments.go @@ -89,7 +89,11 @@ func UpdateComment(ctx context.Context, c *issues_model.Comment, doer *user_mode } if needsContentHistory { - err := issues_model.SaveIssueContentHistory(ctx, doer.ID, c.IssueID, c.ID, timeutil.TimeStampNow(), c.Content, false) + historyDate := timeutil.TimeStampNow() + if c.Issue.NoAutoTime { + historyDate = c.Issue.UpdatedUnix + } + err := issues_model.SaveIssueContentHistory(ctx, doer.ID, c.IssueID, c.ID, historyDate, c.Content, false) if err != nil { return err } diff --git a/templates/swagger/v1_json.tmpl b/templates/swagger/v1_json.tmpl index 3b9d49dd92..ca7672be19 100644 --- a/templates/swagger/v1_json.tmpl +++ b/templates/swagger/v1_json.tmpl @@ -18075,6 +18075,11 @@ "body": { "type": "string", "x-go-name": "Body" + }, + "updated_at": { + "type": "string", + "format": "date-time", + "x-go-name": "Updated" } }, "x-go-package": "code.gitea.io/gitea/modules/structs" From 1e4ff424d39db7a4256cd9abf9c58b8d3e1b5c14 Mon Sep 17 00:00:00 2001 From: fluzz Date: Wed, 26 Jul 2023 09:07:02 +0200 Subject: [PATCH 08/12] Add an updated_at field to the API call for comment's attachment creation The update date is applied to the comment, and is set as the asset creation date. --- models/issues/comment.go | 18 +++++++-- .../api/v1/repo/issue_comment_attachment.go | 37 ++++++++++++++++--- templates/swagger/v1_json.tmpl | 7 ++++ 3 files changed, 53 insertions(+), 9 deletions(-) diff --git a/models/issues/comment.go b/models/issues/comment.go index 0b46f40589..a4b5dec5b7 100644 --- a/models/issues/comment.go +++ b/models/issues/comment.go @@ -1107,13 +1107,23 @@ func UpdateComment(c *Comment, doer *user_model.User) error { } defer committer.Close() sess := db.GetEngine(ctx).ID(c.ID).AllCols() - if c.Issue.NoAutoTime { - c.UpdatedUnix = c.Issue.UpdatedUnix - sess = sess.NoAutoTime() - } if _, err := sess.Update(c); err != nil { return err } + if c.Issue.NoAutoTime { + // AllCols().Update() does not change the "update_unix" field + // even if NoAutoTime is set. + // So, we need to commit the former pending Update() and + // then call an other Update() specifically to set "updated_unix". + if err := committer.Commit(); err != nil { + return fmt.Errorf("Commit: %w", err) + } + c.UpdatedUnix = c.Issue.UpdatedUnix + sess := db.GetEngine(ctx).ID(c.ID).Cols("updated_unix").NoAutoTime() + if _, err := sess.Update(c); err != nil { + return err + } + } if err := c.LoadIssue(ctx); err != nil { return err } diff --git a/routers/api/v1/repo/issue_comment_attachment.go b/routers/api/v1/repo/issue_comment_attachment.go index 121e3f10e0..10ae4f9167 100644 --- a/routers/api/v1/repo/issue_comment_attachment.go +++ b/routers/api/v1/repo/issue_comment_attachment.go @@ -5,6 +5,7 @@ package repo import ( "net/http" + "time" issues_model "code.gitea.io/gitea/models/issues" repo_model "code.gitea.io/gitea/models/repo" @@ -144,6 +145,11 @@ func CreateIssueCommentAttachment(ctx *context.APIContext) { // description: name of the attachment // type: string // required: false + // - name: updated_at + // in: query + // description: time of the attachment's creation. This is a timestamp in RFC 3339 format + // type: string + // format: date-time // - name: attachment // in: formData // description: attachment to upload @@ -167,6 +173,25 @@ func CreateIssueCommentAttachment(ctx *context.APIContext) { return } + updatedAt := ctx.Req.FormValue("updated_at") + if len(updatedAt) != 0 { + updated, err := time.Parse(time.RFC3339, updatedAt) + if err != nil { + ctx.Error(http.StatusInternalServerError, "time.Parse", err) + return + } + err = comment.LoadIssue(ctx) + if err != nil { + ctx.Error(http.StatusInternalServerError, "LoadIssue", err) + return + } + err = issue_service.SetIssueUpdateDate(ctx, comment.Issue, &updated, ctx.Doer) + if err != nil { + ctx.Error(http.StatusForbidden, "SetIssueUpdateDate", err) + return + } + } + // Get uploaded file from request file, header, err := ctx.Req.FormFile("attachment") if err != nil { @@ -181,11 +206,13 @@ func CreateIssueCommentAttachment(ctx *context.APIContext) { } attachment, err := attachment.UploadAttachment(file, setting.Attachment.AllowedTypes, header.Size, &repo_model.Attachment{ - Name: filename, - UploaderID: ctx.Doer.ID, - RepoID: ctx.Repo.Repository.ID, - IssueID: comment.IssueID, - CommentID: comment.ID, + Name: filename, + UploaderID: ctx.Doer.ID, + RepoID: ctx.Repo.Repository.ID, + IssueID: comment.IssueID, + CommentID: comment.ID, + NoAutoTime: comment.Issue.NoAutoTime, + CreatedUnix: comment.Issue.UpdatedUnix, }) if err != nil { ctx.Error(http.StatusInternalServerError, "UploadAttachment", err) diff --git a/templates/swagger/v1_json.tmpl b/templates/swagger/v1_json.tmpl index ca7672be19..ac84ddd922 100644 --- a/templates/swagger/v1_json.tmpl +++ b/templates/swagger/v1_json.tmpl @@ -6120,6 +6120,13 @@ "name": "name", "in": "query" }, + { + "type": "string", + "format": "date-time", + "description": "time of the attachment's creation. This is a timestamp in RFC 3339 format", + "name": "updated_at", + "in": "query" + }, { "type": "file", "description": "attachment to upload", From da932152f1deb3039a399516a51c8b6757059c91 Mon Sep 17 00:00:00 2001 From: fluzz Date: Thu, 27 Jul 2023 09:20:00 +0200 Subject: [PATCH 09/12] Checking Comment changes, with and without providing an updated_at date Those unit tests are added: - TestAPICreateCommentWithAutoDate - TestAPICreateCommentWithNoAutoDate - TestAPIEditCommentWithAutoDate - TestAPIEditCommentWithNoAutoDate - TestAPICreateCommentAttachmentWithAutoDate - TestAPICreateCommentAttachmentWithNoAutoDate --- .../api_comment_attachment_test.go | 87 +++++++++++++ tests/integration/api_comment_test.go | 118 ++++++++++++++++++ 2 files changed, 205 insertions(+) diff --git a/tests/integration/api_comment_attachment_test.go b/tests/integration/api_comment_attachment_test.go index e211376c3c..732e7e8a96 100644 --- a/tests/integration/api_comment_attachment_test.go +++ b/tests/integration/api_comment_attachment_test.go @@ -11,6 +11,7 @@ import ( "mime/multipart" "net/http" "testing" + "time" auth_model "code.gitea.io/gitea/models/auth" "code.gitea.io/gitea/models/db" @@ -111,6 +112,92 @@ func TestAPICreateCommentAttachment(t *testing.T) { unittest.AssertExistsAndLoadBean(t, &repo_model.Attachment{ID: apiAttachment.ID, CommentID: comment.ID}) } +func TestAPICreateCommentAttachmentWithAutoDate(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + comment := unittest.AssertExistsAndLoadBean(t, &issues_model.Comment{ID: 2}) + issue := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: comment.IssueID}) + repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: issue.RepoID}) + repoOwner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID}) + + session := loginUser(t, repoOwner.Name) + token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteIssue) + urlStr := fmt.Sprintf("/api/v1/repos/%s/%s/issues/comments/%d/assets?token=%s", + repoOwner.Name, repo.Name, comment.ID, token) + + filename := "image.png" + buff := generateImg() + body := &bytes.Buffer{} + + // Setup multi-part + writer := multipart.NewWriter(body) + part, err := writer.CreateFormFile("attachment", filename) + assert.NoError(t, err) + _, err = io.Copy(part, &buff) + assert.NoError(t, err) + err = writer.Close() + assert.NoError(t, err) + + req := NewRequestWithBody(t, "POST", urlStr, body) + req.Header.Add("Content-Type", writer.FormDataContentType()) + resp := session.MakeRequest(t, req, http.StatusCreated) + + apiAttachment := new(api.Attachment) + DecodeJSON(t, resp, &apiAttachment) + + unittest.AssertExistsAndLoadBean(t, &repo_model.Attachment{ID: apiAttachment.ID}) + // the execution of the API call supposedly lasted less than one minute + updatedSince := time.Since(apiAttachment.Created) + assert.LessOrEqual(t, updatedSince, time.Minute) + + commentAfter := unittest.AssertExistsAndLoadBean(t, &issues_model.Comment{ID: comment.ID}) + updatedSince = time.Since(commentAfter.UpdatedUnix.AsTime()) + assert.LessOrEqual(t, updatedSince, time.Minute) +} + +func TestAPICreateCommentAttachmentWithNoAutoDate(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + comment := unittest.AssertExistsAndLoadBean(t, &issues_model.Comment{ID: 2}) + issue := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: comment.IssueID}) + repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: issue.RepoID}) + repoOwner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID}) + + session := loginUser(t, repoOwner.Name) + token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteIssue) + updatedAt := time.Now().Add(-time.Hour).Truncate(time.Second) + urlStr := fmt.Sprintf("/api/v1/repos/%s/%s/issues/comments/%d/assets?token=%s&updated_at=%s", + repoOwner.Name, repo.Name, comment.ID, token, updatedAt.UTC().Format(time.RFC3339)) + + filename := "image.png" + buff := generateImg() + body := &bytes.Buffer{} + + // Setup multi-part + writer := multipart.NewWriter(body) + part, err := writer.CreateFormFile("attachment", filename) + assert.NoError(t, err) + _, err = io.Copy(part, &buff) + assert.NoError(t, err) + err = writer.Close() + assert.NoError(t, err) + + req := NewRequestWithBody(t, "POST", urlStr, body) + req.Header.Add("Content-Type", writer.FormDataContentType()) + resp := session.MakeRequest(t, req, http.StatusCreated) + + apiAttachment := new(api.Attachment) + DecodeJSON(t, resp, &apiAttachment) + + // dates will be converted into the same tz, in order to compare them + utcTZ, _ := time.LoadLocation("UTC") + unittest.AssertExistsAndLoadBean(t, &repo_model.Attachment{ID: apiAttachment.ID}) + assert.Equal(t, updatedAt.In(utcTZ), apiAttachment.Created.In(utcTZ)) + + commentAfter := unittest.AssertExistsAndLoadBean(t, &issues_model.Comment{ID: comment.ID}) + assert.Equal(t, updatedAt.In(utcTZ), commentAfter.UpdatedUnix.AsTime().In(utcTZ)) +} + func TestAPIEditCommentAttachment(t *testing.T) { defer tests.PrepareTestEnv(t)() diff --git a/tests/integration/api_comment_test.go b/tests/integration/api_comment_test.go index ee648210e5..b3c249eee0 100644 --- a/tests/integration/api_comment_test.go +++ b/tests/integration/api_comment_test.go @@ -8,6 +8,7 @@ import ( "net/http" "net/url" "testing" + "time" auth_model "code.gitea.io/gitea/models/auth" "code.gitea.io/gitea/models/db" @@ -110,6 +111,63 @@ func TestAPICreateComment(t *testing.T) { unittest.AssertExistsAndLoadBean(t, &issues_model.Comment{ID: updatedComment.ID, IssueID: issue.ID, Content: commentBody}) } +func TestAPICreateCommentWithAutoDate(t *testing.T) { + defer tests.PrepareTestEnv(t)() + const commentBody = "Comment body" + + issue := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{}) + repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: issue.RepoID}) + repoOwner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID}) + + token := getUserToken(t, repoOwner.Name, auth_model.AccessTokenScopeWriteIssue) + urlStr := fmt.Sprintf("/api/v1/repos/%s/%s/issues/%d/comments?token=%s", + repoOwner.Name, repo.Name, issue.Index, token) + req := NewRequestWithValues(t, "POST", urlStr, map[string]string{ + "body": commentBody, + }) + resp := MakeRequest(t, req, http.StatusCreated) + + var updatedComment api.Comment + DecodeJSON(t, resp, &updatedComment) + + // the execution of the API call supposedly lasted less than one minute + updatedSince := time.Since(updatedComment.Updated) + assert.LessOrEqual(t, updatedSince, time.Minute) + + commentAfter := unittest.AssertExistsAndLoadBean(t, &issues_model.Comment{ID: updatedComment.ID, IssueID: issue.ID, Content: commentBody}) + updatedSince = time.Since(commentAfter.UpdatedUnix.AsTime()) + assert.LessOrEqual(t, updatedSince, time.Minute) +} + +func TestAPICreateCommentWithNoAutoDate(t *testing.T) { + defer tests.PrepareTestEnv(t)() + const commentBody = "Comment body" + updatedAt := time.Now().Add(-time.Hour).Truncate(time.Second) + + issue := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{}) + repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: issue.RepoID}) + repoOwner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID}) + + token := getUserToken(t, repoOwner.Name, auth_model.AccessTokenScopeWriteIssue) + urlStr := fmt.Sprintf("/api/v1/repos/%s/%s/issues/%d/comments?token=%s", + repoOwner.Name, repo.Name, issue.Index, token) + req := NewRequestWithJSON(t, "POST", urlStr, &api.CreateIssueCommentOption{ + Body: commentBody, + Updated: &updatedAt, + }) + + resp := MakeRequest(t, req, http.StatusCreated) + + var updatedComment api.Comment + DecodeJSON(t, resp, &updatedComment) + + // dates will be converted into the same tz, in order to compare them + utcTZ, _ := time.LoadLocation("UTC") + assert.Equal(t, updatedAt.In(utcTZ), updatedComment.Updated.In(utcTZ)) + commentAfter := unittest.AssertExistsAndLoadBean(t, &issues_model.Comment{ID: updatedComment.ID, IssueID: issue.ID, Content: commentBody}) + assert.Equal(t, updatedAt.In(utcTZ), commentAfter.UpdatedUnix.AsTime().In(utcTZ)) +} + func TestAPIGetComment(t *testing.T) { defer tests.PrepareTestEnv(t)() @@ -161,6 +219,66 @@ func TestAPIEditComment(t *testing.T) { unittest.AssertExistsAndLoadBean(t, &issues_model.Comment{ID: comment.ID, IssueID: issue.ID, Content: newCommentBody}) } +func TestAPIEditCommentWithAutoDate(t *testing.T) { + defer tests.PrepareTestEnv(t)() + const newCommentBody = "This is the new comment body" + + comment := unittest.AssertExistsAndLoadBean(t, &issues_model.Comment{}, + unittest.Cond("type = ?", issues_model.CommentTypeComment)) + issue := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: comment.IssueID}) + repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: issue.RepoID}) + repoOwner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID}) + + token := getUserToken(t, repoOwner.Name, auth_model.AccessTokenScopeWriteIssue) + urlStr := fmt.Sprintf("/api/v1/repos/%s/%s/issues/comments/%d?token=%s", + repoOwner.Name, repo.Name, comment.ID, token) + req := NewRequestWithValues(t, "PATCH", urlStr, map[string]string{ + "body": newCommentBody, + }) + resp := MakeRequest(t, req, http.StatusOK) + + var updatedComment api.Comment + DecodeJSON(t, resp, &updatedComment) + + // the execution of the API call supposedly lasted less than one minute + updatedSince := time.Since(updatedComment.Updated) + assert.LessOrEqual(t, updatedSince, time.Minute) + + commentAfter := unittest.AssertExistsAndLoadBean(t, &issues_model.Comment{ID: comment.ID, IssueID: issue.ID, Content: newCommentBody}) + updatedSince = time.Since(commentAfter.UpdatedUnix.AsTime()) + assert.LessOrEqual(t, updatedSince, time.Minute) +} + +func TestAPIEditCommentWithNoAutoDate(t *testing.T) { + defer tests.PrepareTestEnv(t)() + const newCommentBody = "This is the new comment body" + updatedAt := time.Now().Add(-time.Hour).Truncate(time.Second) + + comment := unittest.AssertExistsAndLoadBean(t, &issues_model.Comment{}, + unittest.Cond("type = ?", issues_model.CommentTypeComment)) + issue := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: comment.IssueID}) + repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: issue.RepoID}) + repoOwner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID}) + + token := getUserToken(t, repoOwner.Name, auth_model.AccessTokenScopeWriteIssue) + urlStr := fmt.Sprintf("/api/v1/repos/%s/%s/issues/comments/%d?token=%s", + repoOwner.Name, repo.Name, comment.ID, token) + req := NewRequestWithJSON(t, "PATCH", urlStr, &api.EditIssueCommentOption{ + Body: newCommentBody, + Updated: &updatedAt, + }) + resp := MakeRequest(t, req, http.StatusOK) + + var updatedComment api.Comment + DecodeJSON(t, resp, &updatedComment) + + // dates will be converted into the same tz, in order to compare them + utcTZ, _ := time.LoadLocation("UTC") + assert.Equal(t, updatedAt.In(utcTZ), updatedComment.Updated.In(utcTZ)) + commentAfter := unittest.AssertExistsAndLoadBean(t, &issues_model.Comment{ID: comment.ID, IssueID: issue.ID, Content: newCommentBody}) + assert.Equal(t, updatedAt.In(utcTZ), commentAfter.UpdatedUnix.AsTime().In(utcTZ)) +} + func TestAPIDeleteComment(t *testing.T) { defer tests.PrepareTestEnv(t)() From 1f6a42808dd739c0c2e49e6b7ae2967f120f43c2 Mon Sep 17 00:00:00 2001 From: fluzz Date: Thu, 3 Aug 2023 09:44:21 +0200 Subject: [PATCH 10/12] Pettier code to set the update time of comments Now uses sess.AllCols().NoAutoToime().SetExpr("updated_unix", ...) XORM is smart enough to compose one single SQL UPDATE which all columns + updated_unix. --- models/issues/comment.go | 23 +++++++++-------------- 1 file changed, 9 insertions(+), 14 deletions(-) diff --git a/models/issues/comment.go b/models/issues/comment.go index a4b5dec5b7..e730308704 100644 --- a/models/issues/comment.go +++ b/models/issues/comment.go @@ -1106,24 +1106,19 @@ func UpdateComment(c *Comment, doer *user_model.User) error { return err } defer committer.Close() + sess := db.GetEngine(ctx).ID(c.ID).AllCols() + if c.Issue.NoAutoTime { + // update the DataBase + sess = sess.NoAutoTime().SetExpr("updated_unix", c.Issue.UpdatedUnix) + // the UpdatedUnix value of the Comment also has to be set, + // to return the adequate valuè + // see https://codeberg.org/forgejo/forgejo/pulls/764#issuecomment-1023801 + c.UpdatedUnix = c.Issue.UpdatedUnix + } if _, err := sess.Update(c); err != nil { return err } - if c.Issue.NoAutoTime { - // AllCols().Update() does not change the "update_unix" field - // even if NoAutoTime is set. - // So, we need to commit the former pending Update() and - // then call an other Update() specifically to set "updated_unix". - if err := committer.Commit(); err != nil { - return fmt.Errorf("Commit: %w", err) - } - c.UpdatedUnix = c.Issue.UpdatedUnix - sess := db.GetEngine(ctx).ID(c.ID).Cols("updated_unix").NoAutoTime() - if _, err := sess.Update(c); err != nil { - return err - } - } if err := c.LoadIssue(ctx); err != nil { return err } From 8f22ea182e6b49e933dc6534040160dd739ff18a Mon Sep 17 00:00:00 2001 From: fluzz Date: Thu, 10 Aug 2023 19:35:32 +0200 Subject: [PATCH 11/12] Issue edition: Keep the max of the milestone and issue update dates. When editing an issue via the API, an updated_at date can be provided. If the EditIssue call changes the issue's milestone, the milestone's update date is to be changed accordingly, but only with a greater value. This ensures that a milestone's update date is the max of all issue's update dates. --- services/issue/milestone.go | 46 ++++++++++++++++++++++++------------- 1 file changed, 30 insertions(+), 16 deletions(-) diff --git a/services/issue/milestone.go b/services/issue/milestone.go index 6de3ce2d8b..55e9cb69c1 100644 --- a/services/issue/milestone.go +++ b/services/issue/milestone.go @@ -13,6 +13,32 @@ import ( "code.gitea.io/gitea/modules/notification" ) +func updateMilestoneCounters(ctx context.Context, issue *issues_model.Issue, id int64) error { + if issue.NoAutoTime { + // We set the milestone's update date to the max of the + // milestone and issue update dates. + // Note: we can not call UpdateMilestoneCounters() if the + // milestone's update date is to be kept, because that function + // auto-updates the dates. + milestone, err := issues_model.GetMilestoneByRepoID(ctx, issue.RepoID, id) + if err != nil { + return fmt.Errorf("GetMilestoneByRepoID: %w", err) + } + updatedUnix := milestone.UpdatedUnix + if issue.UpdatedUnix > updatedUnix { + updatedUnix = issue.UpdatedUnix + } + if err := issues_model.UpdateMilestoneCountersWithDate(ctx, id, updatedUnix); err != nil { + return err + } + } else { + if err := issues_model.UpdateMilestoneCounters(ctx, id); err != nil { + return err + } + } + return nil +} + func changeMilestoneAssign(ctx context.Context, doer *user_model.User, issue *issues_model.Issue, oldMilestoneID int64) error { // Only check if milestone exists if we don't remove it. if issue.MilestoneID > 0 { @@ -30,26 +56,14 @@ func changeMilestoneAssign(ctx context.Context, doer *user_model.User, issue *is } if oldMilestoneID > 0 { - if issue.NoAutoTime { - if err := issues_model.UpdateMilestoneCountersWithDate(ctx, oldMilestoneID, issue.UpdatedUnix); err != nil { - return err - } - } else { - if err := issues_model.UpdateMilestoneCounters(ctx, oldMilestoneID); err != nil { - return err - } + if err := updateMilestoneCounters(ctx, issue, oldMilestoneID); err != nil { + return err } } if issue.MilestoneID > 0 { - if issue.NoAutoTime { - if err := issues_model.UpdateMilestoneCountersWithDate(ctx, issue.MilestoneID, issue.UpdatedUnix); err != nil { - return err - } - } else { - if err := issues_model.UpdateMilestoneCounters(ctx, issue.MilestoneID); err != nil { - return err - } + if err := updateMilestoneCounters(ctx, issue, issue.MilestoneID); err != nil { + return err } } From 961fd13c551b3e50040acb7c914a00ead92de63f Mon Sep 17 00:00:00 2001 From: fluzz Date: Thu, 24 Aug 2023 12:03:59 +0200 Subject: [PATCH 12/12] Rewrite the 'AutoDate' tests using subtests Also add a test to check the permissions to set a date, and a test to check update dates on milestones. The tests related to 'AutoDate' are: - TestAPIEditIssueAutoDate - TestAPIAddIssueLabelsAutoDate - TestAPIEditIssueMilestoneAutoDate - TestAPICreateIssueAttachmentAutoDate - TestAPICreateCommentAutoDate - TestAPIEditCommentWithDate - TestAPICreateCommentAttachmentAutoDate --- .../api_comment_attachment_test.go | 106 ++++++------ tests/integration/api_comment_test.go | 153 ++++++++--------- .../integration/api_issue_attachment_test.go | 119 ++++++------- tests/integration/api_issue_label_test.go | 71 ++++---- tests/integration/api_issue_test.go | 160 ++++++++++++++---- 5 files changed, 332 insertions(+), 277 deletions(-) diff --git a/tests/integration/api_comment_attachment_test.go b/tests/integration/api_comment_attachment_test.go index 732e7e8a96..9761e06987 100644 --- a/tests/integration/api_comment_attachment_test.go +++ b/tests/integration/api_comment_attachment_test.go @@ -112,7 +112,7 @@ func TestAPICreateCommentAttachment(t *testing.T) { unittest.AssertExistsAndLoadBean(t, &repo_model.Attachment{ID: apiAttachment.ID, CommentID: comment.ID}) } -func TestAPICreateCommentAttachmentWithAutoDate(t *testing.T) { +func TestAPICreateCommentAttachmentAutoDate(t *testing.T) { defer tests.PrepareTestEnv(t)() comment := unittest.AssertExistsAndLoadBean(t, &issues_model.Comment{ID: 2}) @@ -129,73 +129,63 @@ func TestAPICreateCommentAttachmentWithAutoDate(t *testing.T) { buff := generateImg() body := &bytes.Buffer{} - // Setup multi-part - writer := multipart.NewWriter(body) - part, err := writer.CreateFormFile("attachment", filename) - assert.NoError(t, err) - _, err = io.Copy(part, &buff) - assert.NoError(t, err) - err = writer.Close() - assert.NoError(t, err) + t.Run("WithAutoDate", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() - req := NewRequestWithBody(t, "POST", urlStr, body) - req.Header.Add("Content-Type", writer.FormDataContentType()) - resp := session.MakeRequest(t, req, http.StatusCreated) + // Setup multi-part + writer := multipart.NewWriter(body) + part, err := writer.CreateFormFile("attachment", filename) + assert.NoError(t, err) + _, err = io.Copy(part, &buff) + assert.NoError(t, err) + err = writer.Close() + assert.NoError(t, err) - apiAttachment := new(api.Attachment) - DecodeJSON(t, resp, &apiAttachment) + req := NewRequestWithBody(t, "POST", urlStr, body) + req.Header.Add("Content-Type", writer.FormDataContentType()) + resp := session.MakeRequest(t, req, http.StatusCreated) + apiAttachment := new(api.Attachment) + DecodeJSON(t, resp, &apiAttachment) - unittest.AssertExistsAndLoadBean(t, &repo_model.Attachment{ID: apiAttachment.ID}) - // the execution of the API call supposedly lasted less than one minute - updatedSince := time.Since(apiAttachment.Created) - assert.LessOrEqual(t, updatedSince, time.Minute) + unittest.AssertExistsAndLoadBean(t, &repo_model.Attachment{ID: apiAttachment.ID}) + // the execution of the API call supposedly lasted less than one minute + updatedSince := time.Since(apiAttachment.Created) + assert.LessOrEqual(t, updatedSince, time.Minute) - commentAfter := unittest.AssertExistsAndLoadBean(t, &issues_model.Comment{ID: comment.ID}) - updatedSince = time.Since(commentAfter.UpdatedUnix.AsTime()) - assert.LessOrEqual(t, updatedSince, time.Minute) -} + commentAfter := unittest.AssertExistsAndLoadBean(t, &issues_model.Comment{ID: comment.ID}) + updatedSince = time.Since(commentAfter.UpdatedUnix.AsTime()) + assert.LessOrEqual(t, updatedSince, time.Minute) + }) -func TestAPICreateCommentAttachmentWithNoAutoDate(t *testing.T) { - defer tests.PrepareTestEnv(t)() + t.Run("WithUpdateDate", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() - comment := unittest.AssertExistsAndLoadBean(t, &issues_model.Comment{ID: 2}) - issue := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: comment.IssueID}) - repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: issue.RepoID}) - repoOwner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID}) + updatedAt := time.Now().Add(-time.Hour).Truncate(time.Second) + urlStr += fmt.Sprintf("&updated_at=%s", updatedAt.UTC().Format(time.RFC3339)) - session := loginUser(t, repoOwner.Name) - token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteIssue) - updatedAt := time.Now().Add(-time.Hour).Truncate(time.Second) - urlStr := fmt.Sprintf("/api/v1/repos/%s/%s/issues/comments/%d/assets?token=%s&updated_at=%s", - repoOwner.Name, repo.Name, comment.ID, token, updatedAt.UTC().Format(time.RFC3339)) + // Setup multi-part + writer := multipart.NewWriter(body) + part, err := writer.CreateFormFile("attachment", filename) + assert.NoError(t, err) + _, err = io.Copy(part, &buff) + assert.NoError(t, err) + err = writer.Close() + assert.NoError(t, err) - filename := "image.png" - buff := generateImg() - body := &bytes.Buffer{} + req := NewRequestWithBody(t, "POST", urlStr, body) + req.Header.Add("Content-Type", writer.FormDataContentType()) + resp := session.MakeRequest(t, req, http.StatusCreated) + apiAttachment := new(api.Attachment) + DecodeJSON(t, resp, &apiAttachment) - // Setup multi-part - writer := multipart.NewWriter(body) - part, err := writer.CreateFormFile("attachment", filename) - assert.NoError(t, err) - _, err = io.Copy(part, &buff) - assert.NoError(t, err) - err = writer.Close() - assert.NoError(t, err) + // dates will be converted into the same tz, in order to compare them + utcTZ, _ := time.LoadLocation("UTC") + unittest.AssertExistsAndLoadBean(t, &repo_model.Attachment{ID: apiAttachment.ID}) + assert.Equal(t, updatedAt.In(utcTZ), apiAttachment.Created.In(utcTZ)) - req := NewRequestWithBody(t, "POST", urlStr, body) - req.Header.Add("Content-Type", writer.FormDataContentType()) - resp := session.MakeRequest(t, req, http.StatusCreated) - - apiAttachment := new(api.Attachment) - DecodeJSON(t, resp, &apiAttachment) - - // dates will be converted into the same tz, in order to compare them - utcTZ, _ := time.LoadLocation("UTC") - unittest.AssertExistsAndLoadBean(t, &repo_model.Attachment{ID: apiAttachment.ID}) - assert.Equal(t, updatedAt.In(utcTZ), apiAttachment.Created.In(utcTZ)) - - commentAfter := unittest.AssertExistsAndLoadBean(t, &issues_model.Comment{ID: comment.ID}) - assert.Equal(t, updatedAt.In(utcTZ), commentAfter.UpdatedUnix.AsTime().In(utcTZ)) + commentAfter := unittest.AssertExistsAndLoadBean(t, &issues_model.Comment{ID: comment.ID}) + assert.Equal(t, updatedAt.In(utcTZ), commentAfter.UpdatedUnix.AsTime().In(utcTZ)) + }) } func TestAPIEditCommentAttachment(t *testing.T) { diff --git a/tests/integration/api_comment_test.go b/tests/integration/api_comment_test.go index b3c249eee0..339ffdbe0e 100644 --- a/tests/integration/api_comment_test.go +++ b/tests/integration/api_comment_test.go @@ -111,61 +111,56 @@ func TestAPICreateComment(t *testing.T) { unittest.AssertExistsAndLoadBean(t, &issues_model.Comment{ID: updatedComment.ID, IssueID: issue.ID, Content: commentBody}) } -func TestAPICreateCommentWithAutoDate(t *testing.T) { +func TestAPICreateCommentAutoDate(t *testing.T) { defer tests.PrepareTestEnv(t)() - const commentBody = "Comment body" issue := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{}) repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: issue.RepoID}) repoOwner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID}) - token := getUserToken(t, repoOwner.Name, auth_model.AccessTokenScopeWriteIssue) + urlStr := fmt.Sprintf("/api/v1/repos/%s/%s/issues/%d/comments?token=%s", repoOwner.Name, repo.Name, issue.Index, token) - req := NewRequestWithValues(t, "POST", urlStr, map[string]string{ - "body": commentBody, - }) - resp := MakeRequest(t, req, http.StatusCreated) - - var updatedComment api.Comment - DecodeJSON(t, resp, &updatedComment) - - // the execution of the API call supposedly lasted less than one minute - updatedSince := time.Since(updatedComment.Updated) - assert.LessOrEqual(t, updatedSince, time.Minute) - - commentAfter := unittest.AssertExistsAndLoadBean(t, &issues_model.Comment{ID: updatedComment.ID, IssueID: issue.ID, Content: commentBody}) - updatedSince = time.Since(commentAfter.UpdatedUnix.AsTime()) - assert.LessOrEqual(t, updatedSince, time.Minute) -} - -func TestAPICreateCommentWithNoAutoDate(t *testing.T) { - defer tests.PrepareTestEnv(t)() const commentBody = "Comment body" - updatedAt := time.Now().Add(-time.Hour).Truncate(time.Second) - issue := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{}) - repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: issue.RepoID}) - repoOwner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID}) + t.Run("WithAutoDate", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() - token := getUserToken(t, repoOwner.Name, auth_model.AccessTokenScopeWriteIssue) - urlStr := fmt.Sprintf("/api/v1/repos/%s/%s/issues/%d/comments?token=%s", - repoOwner.Name, repo.Name, issue.Index, token) - req := NewRequestWithJSON(t, "POST", urlStr, &api.CreateIssueCommentOption{ - Body: commentBody, - Updated: &updatedAt, + req := NewRequestWithValues(t, "POST", urlStr, map[string]string{ + "body": commentBody, + }) + resp := MakeRequest(t, req, http.StatusCreated) + var updatedComment api.Comment + DecodeJSON(t, resp, &updatedComment) + + // the execution of the API call supposedly lasted less than one minute + updatedSince := time.Since(updatedComment.Updated) + assert.LessOrEqual(t, updatedSince, time.Minute) + + commentAfter := unittest.AssertExistsAndLoadBean(t, &issues_model.Comment{ID: updatedComment.ID, IssueID: issue.ID, Content: commentBody}) + updatedSince = time.Since(commentAfter.UpdatedUnix.AsTime()) + assert.LessOrEqual(t, updatedSince, time.Minute) }) - resp := MakeRequest(t, req, http.StatusCreated) + t.Run("WithUpdateDate", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() - var updatedComment api.Comment - DecodeJSON(t, resp, &updatedComment) + updatedAt := time.Now().Add(-time.Hour).Truncate(time.Second) - // dates will be converted into the same tz, in order to compare them - utcTZ, _ := time.LoadLocation("UTC") - assert.Equal(t, updatedAt.In(utcTZ), updatedComment.Updated.In(utcTZ)) - commentAfter := unittest.AssertExistsAndLoadBean(t, &issues_model.Comment{ID: updatedComment.ID, IssueID: issue.ID, Content: commentBody}) - assert.Equal(t, updatedAt.In(utcTZ), commentAfter.UpdatedUnix.AsTime().In(utcTZ)) + req := NewRequestWithJSON(t, "POST", urlStr, &api.CreateIssueCommentOption{ + Body: commentBody, + Updated: &updatedAt, + }) + resp := MakeRequest(t, req, http.StatusCreated) + var updatedComment api.Comment + DecodeJSON(t, resp, &updatedComment) + + // dates will be converted into the same tz, in order to compare them + utcTZ, _ := time.LoadLocation("UTC") + assert.Equal(t, updatedAt.In(utcTZ), updatedComment.Updated.In(utcTZ)) + commentAfter := unittest.AssertExistsAndLoadBean(t, &issues_model.Comment{ID: updatedComment.ID, IssueID: issue.ID, Content: commentBody}) + assert.Equal(t, updatedAt.In(utcTZ), commentAfter.UpdatedUnix.AsTime().In(utcTZ)) + }) } func TestAPIGetComment(t *testing.T) { @@ -219,64 +214,58 @@ func TestAPIEditComment(t *testing.T) { unittest.AssertExistsAndLoadBean(t, &issues_model.Comment{ID: comment.ID, IssueID: issue.ID, Content: newCommentBody}) } -func TestAPIEditCommentWithAutoDate(t *testing.T) { +func TestAPIEditCommentWithDate(t *testing.T) { defer tests.PrepareTestEnv(t)() - const newCommentBody = "This is the new comment body" comment := unittest.AssertExistsAndLoadBean(t, &issues_model.Comment{}, unittest.Cond("type = ?", issues_model.CommentTypeComment)) issue := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: comment.IssueID}) repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: issue.RepoID}) repoOwner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID}) - token := getUserToken(t, repoOwner.Name, auth_model.AccessTokenScopeWriteIssue) + urlStr := fmt.Sprintf("/api/v1/repos/%s/%s/issues/comments/%d?token=%s", repoOwner.Name, repo.Name, comment.ID, token) - req := NewRequestWithValues(t, "PATCH", urlStr, map[string]string{ - "body": newCommentBody, - }) - resp := MakeRequest(t, req, http.StatusOK) - - var updatedComment api.Comment - DecodeJSON(t, resp, &updatedComment) - - // the execution of the API call supposedly lasted less than one minute - updatedSince := time.Since(updatedComment.Updated) - assert.LessOrEqual(t, updatedSince, time.Minute) - - commentAfter := unittest.AssertExistsAndLoadBean(t, &issues_model.Comment{ID: comment.ID, IssueID: issue.ID, Content: newCommentBody}) - updatedSince = time.Since(commentAfter.UpdatedUnix.AsTime()) - assert.LessOrEqual(t, updatedSince, time.Minute) -} - -func TestAPIEditCommentWithNoAutoDate(t *testing.T) { - defer tests.PrepareTestEnv(t)() const newCommentBody = "This is the new comment body" - updatedAt := time.Now().Add(-time.Hour).Truncate(time.Second) - comment := unittest.AssertExistsAndLoadBean(t, &issues_model.Comment{}, - unittest.Cond("type = ?", issues_model.CommentTypeComment)) - issue := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: comment.IssueID}) - repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: issue.RepoID}) - repoOwner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID}) + t.Run("WithAutoDate", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() - token := getUserToken(t, repoOwner.Name, auth_model.AccessTokenScopeWriteIssue) - urlStr := fmt.Sprintf("/api/v1/repos/%s/%s/issues/comments/%d?token=%s", - repoOwner.Name, repo.Name, comment.ID, token) - req := NewRequestWithJSON(t, "PATCH", urlStr, &api.EditIssueCommentOption{ - Body: newCommentBody, - Updated: &updatedAt, + req := NewRequestWithValues(t, "PATCH", urlStr, map[string]string{ + "body": newCommentBody, + }) + resp := MakeRequest(t, req, http.StatusOK) + var updatedComment api.Comment + DecodeJSON(t, resp, &updatedComment) + + // the execution of the API call supposedly lasted less than one minute + updatedSince := time.Since(updatedComment.Updated) + assert.LessOrEqual(t, updatedSince, time.Minute) + + commentAfter := unittest.AssertExistsAndLoadBean(t, &issues_model.Comment{ID: comment.ID, IssueID: issue.ID, Content: newCommentBody}) + updatedSince = time.Since(commentAfter.UpdatedUnix.AsTime()) + assert.LessOrEqual(t, updatedSince, time.Minute) }) - resp := MakeRequest(t, req, http.StatusOK) - var updatedComment api.Comment - DecodeJSON(t, resp, &updatedComment) + t.Run("WithUpdateDate", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() - // dates will be converted into the same tz, in order to compare them - utcTZ, _ := time.LoadLocation("UTC") - assert.Equal(t, updatedAt.In(utcTZ), updatedComment.Updated.In(utcTZ)) - commentAfter := unittest.AssertExistsAndLoadBean(t, &issues_model.Comment{ID: comment.ID, IssueID: issue.ID, Content: newCommentBody}) - assert.Equal(t, updatedAt.In(utcTZ), commentAfter.UpdatedUnix.AsTime().In(utcTZ)) + updatedAt := time.Now().Add(-time.Hour).Truncate(time.Second) + + req := NewRequestWithJSON(t, "PATCH", urlStr, &api.EditIssueCommentOption{ + Body: newCommentBody, + Updated: &updatedAt, + }) + resp := MakeRequest(t, req, http.StatusOK) + var updatedComment api.Comment + DecodeJSON(t, resp, &updatedComment) + + // dates will be converted into the same tz, in order to compare them + utcTZ, _ := time.LoadLocation("UTC") + assert.Equal(t, updatedAt.In(utcTZ), updatedComment.Updated.In(utcTZ)) + commentAfter := unittest.AssertExistsAndLoadBean(t, &issues_model.Comment{ID: comment.ID, IssueID: issue.ID, Content: newCommentBody}) + assert.Equal(t, updatedAt.In(utcTZ), commentAfter.UpdatedUnix.AsTime().In(utcTZ)) + }) } func TestAPIDeleteComment(t *testing.T) { diff --git a/tests/integration/api_issue_attachment_test.go b/tests/integration/api_issue_attachment_test.go index ba72df2400..2250646354 100644 --- a/tests/integration/api_issue_attachment_test.go +++ b/tests/integration/api_issue_attachment_test.go @@ -101,7 +101,7 @@ func TestAPICreateIssueAttachment(t *testing.T) { unittest.AssertExistsAndLoadBean(t, &repo_model.Attachment{ID: apiAttachment.ID, IssueID: issue.ID}) } -func TestAPICreateIssueAttachmentWithAutoDate(t *testing.T) { +func TestAPICreateIssueAttachmentAutoDate(t *testing.T) { defer tests.PrepareTestEnv(t)() repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1}) @@ -110,80 +110,71 @@ func TestAPICreateIssueAttachmentWithAutoDate(t *testing.T) { session := loginUser(t, repoOwner.Name) token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteIssue) - - filename := "image.png" - buff := generateImg() - body := &bytes.Buffer{} - - // Setup multi-part - writer := multipart.NewWriter(body) - part, err := writer.CreateFormFile("attachment", filename) - assert.NoError(t, err) - _, err = io.Copy(part, &buff) - assert.NoError(t, err) - err = writer.Close() - assert.NoError(t, err) - urlStr := fmt.Sprintf("/api/v1/repos/%s/%s/issues/%d/assets?token=%s", repoOwner.Name, repo.Name, issue.Index, token) - req := NewRequestWithBody(t, "POST", urlStr, body) - req.Header.Add("Content-Type", writer.FormDataContentType()) - resp := session.MakeRequest(t, req, http.StatusCreated) - - apiAttachment := new(api.Attachment) - DecodeJSON(t, resp, &apiAttachment) - - unittest.AssertExistsAndLoadBean(t, &repo_model.Attachment{ID: apiAttachment.ID, IssueID: issue.ID}) - // the execution of the API call supposedly lasted less than one minute - updatedSince := time.Since(apiAttachment.Created) - assert.LessOrEqual(t, updatedSince, time.Minute) - - issueAfter := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: issue.Index}) - updatedSince = time.Since(issueAfter.UpdatedUnix.AsTime()) - assert.LessOrEqual(t, updatedSince, time.Minute) -} - -func TestAPICreateIssueAttachmentWithNoAutoDate(t *testing.T) { - defer tests.PrepareTestEnv(t)() - - repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1}) - issue := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{RepoID: repo.ID}) - repoOwner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID}) - - session := loginUser(t, repoOwner.Name) - token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteIssue) - filename := "image.png" buff := generateImg() body := &bytes.Buffer{} - // Setup multi-part - writer := multipart.NewWriter(body) - part, err := writer.CreateFormFile("attachment", filename) - assert.NoError(t, err) - _, err = io.Copy(part, &buff) - assert.NoError(t, err) - err = writer.Close() - assert.NoError(t, err) + t.Run("WithAutoDate", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() - updatedAt := time.Now().Add(-time.Hour).Truncate(time.Second) - urlStr := fmt.Sprintf("/api/v1/repos/%s/%s/issues/%d/assets?token=%s&updated_at=%s", - repoOwner.Name, repo.Name, issue.Index, token, updatedAt.UTC().Format(time.RFC3339)) + // Setup multi-part + writer := multipart.NewWriter(body) + part, err := writer.CreateFormFile("attachment", filename) + assert.NoError(t, err) + _, err = io.Copy(part, &buff) + assert.NoError(t, err) + err = writer.Close() + assert.NoError(t, err) - req := NewRequestWithBody(t, "POST", urlStr, body) - req.Header.Add("Content-Type", writer.FormDataContentType()) - resp := session.MakeRequest(t, req, http.StatusCreated) + req := NewRequestWithBody(t, "POST", urlStr, body) + req.Header.Add("Content-Type", writer.FormDataContentType()) + resp := session.MakeRequest(t, req, http.StatusCreated) - apiAttachment := new(api.Attachment) - DecodeJSON(t, resp, &apiAttachment) + apiAttachment := new(api.Attachment) + DecodeJSON(t, resp, &apiAttachment) - // dates will be converted into the same tz, in order to compare them - utcTZ, _ := time.LoadLocation("UTC") - unittest.AssertExistsAndLoadBean(t, &repo_model.Attachment{ID: apiAttachment.ID, IssueID: issue.ID}) - assert.Equal(t, updatedAt.In(utcTZ), apiAttachment.Created.In(utcTZ)) - issueAfter := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: issue.ID}) - assert.Equal(t, updatedAt.In(utcTZ), issueAfter.UpdatedUnix.AsTime().In(utcTZ)) + unittest.AssertExistsAndLoadBean(t, &repo_model.Attachment{ID: apiAttachment.ID, IssueID: issue.ID}) + // the execution of the API call supposedly lasted less than one minute + updatedSince := time.Since(apiAttachment.Created) + assert.LessOrEqual(t, updatedSince, time.Minute) + + issueAfter := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: issue.Index}) + updatedSince = time.Since(issueAfter.UpdatedUnix.AsTime()) + assert.LessOrEqual(t, updatedSince, time.Minute) + }) + + t.Run("WithUpdateDate", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + updatedAt := time.Now().Add(-time.Hour).Truncate(time.Second) + urlStr += fmt.Sprintf("&updated_at=%s", updatedAt.UTC().Format(time.RFC3339)) + + // Setup multi-part + writer := multipart.NewWriter(body) + part, err := writer.CreateFormFile("attachment", filename) + assert.NoError(t, err) + _, err = io.Copy(part, &buff) + assert.NoError(t, err) + err = writer.Close() + assert.NoError(t, err) + + req := NewRequestWithBody(t, "POST", urlStr, body) + req.Header.Add("Content-Type", writer.FormDataContentType()) + resp := session.MakeRequest(t, req, http.StatusCreated) + + apiAttachment := new(api.Attachment) + DecodeJSON(t, resp, &apiAttachment) + + // dates will be converted into the same tz, in order to compare them + utcTZ, _ := time.LoadLocation("UTC") + unittest.AssertExistsAndLoadBean(t, &repo_model.Attachment{ID: apiAttachment.ID, IssueID: issue.ID}) + assert.Equal(t, updatedAt.In(utcTZ), apiAttachment.Created.In(utcTZ)) + issueAfter := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: issue.ID}) + assert.Equal(t, updatedAt.In(utcTZ), issueAfter.UpdatedUnix.AsTime().In(utcTZ)) + }) } func TestAPIEditIssueAttachment(t *testing.T) { diff --git a/tests/integration/api_issue_label_test.go b/tests/integration/api_issue_label_test.go index 3c2bed6be0..a29c75727f 100644 --- a/tests/integration/api_issue_label_test.go +++ b/tests/integration/api_issue_label_test.go @@ -16,6 +16,7 @@ import ( "code.gitea.io/gitea/models/unittest" user_model "code.gitea.io/gitea/models/user" api "code.gitea.io/gitea/modules/structs" + "code.gitea.io/gitea/tests" "github.com/stretchr/testify/assert" ) @@ -112,57 +113,47 @@ func TestAPIAddIssueLabels(t *testing.T) { unittest.AssertExistsAndLoadBean(t, &issues_model.IssueLabel{IssueID: issue.ID, LabelID: 2}) } -func TestAPIAddIssueLabelsWithAutoDate(t *testing.T) { - assert.NoError(t, unittest.LoadFixtures()) +func TestAPIAddIssueLabelsAutoDate(t *testing.T) { + defer tests.PrepareTestEnv(t)() - repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1}) - issue := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{RepoID: repo.ID}) - _ = unittest.AssertExistsAndLoadBean(t, &issues_model.Label{RepoID: repo.ID, ID: 2}) + issueBefore := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: 3}) + repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: issueBefore.RepoID}) owner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID}) session := loginUser(t, owner.Name) token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteIssue) - urlStr := fmt.Sprintf("/api/v1/repos/%s/%s/issues/%d/labels?token=%s", - repo.OwnerName, repo.Name, issue.Index, token) - req := NewRequestWithJSON(t, "POST", urlStr, &api.IssueLabelsOption{ - Labels: []int64{1, 2}, + owner.Name, repo.Name, issueBefore.Index, token) + + t.Run("WithAutoDate", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := NewRequestWithJSON(t, "POST", urlStr, &api.IssueLabelsOption{ + Labels: []int64{1}, + }) + MakeRequest(t, req, http.StatusOK) + + issueAfter := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: issueBefore.ID}) + // the execution of the API call supposedly lasted less than one minute + updatedSince := time.Since(issueAfter.UpdatedUnix.AsTime()) + assert.LessOrEqual(t, updatedSince, time.Minute) }) - resp := MakeRequest(t, req, http.StatusOK) - var apiLabels []*api.Label - DecodeJSON(t, resp, &apiLabels) - issueAfter := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: issue.Index}) - // the execution of the API call supposedly lasted less than one minute - updatedSince := time.Since(issueAfter.UpdatedUnix.AsTime()) - assert.LessOrEqual(t, updatedSince, time.Minute) -} + t.Run("WithUpdatedDate", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() -func TestAPIAddIssueLabelsWithNoAutoDate(t *testing.T) { - assert.NoError(t, unittest.LoadFixtures()) + updatedAt := time.Now().Add(-time.Hour).Truncate(time.Second) + req := NewRequestWithJSON(t, "POST", urlStr, &api.IssueLabelsOption{ + Labels: []int64{2}, + Updated: &updatedAt, + }) + MakeRequest(t, req, http.StatusOK) - repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1}) - issue := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{RepoID: repo.ID}) - _ = unittest.AssertExistsAndLoadBean(t, &issues_model.Label{RepoID: repo.ID, ID: 2}) - owner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID}) - - session := loginUser(t, owner.Name) - token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteIssue) - updatedAt := time.Now().Add(-time.Hour).Truncate(time.Second) - - urlStr := fmt.Sprintf("/api/v1/repos/%s/%s/issues/%d/labels?token=%s", - repo.OwnerName, repo.Name, issue.Index, token) - req := NewRequestWithJSON(t, "POST", urlStr, &api.IssueLabelsOption{ - Labels: []int64{1, 2}, - Updated: &updatedAt, + // dates will be converted into the same tz, in order to compare them + utcTZ, _ := time.LoadLocation("UTC") + issueAfter := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: issueBefore.ID}) + assert.Equal(t, updatedAt.In(utcTZ), issueAfter.UpdatedUnix.AsTime().In(utcTZ)) }) - resp := MakeRequest(t, req, http.StatusOK) - var apiLabels []*api.Label - DecodeJSON(t, resp, &apiLabels) - // dates will be converted into the same tz, in order to compare them - utcTZ, _ := time.LoadLocation("UTC") - issueAfter := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: issue.Index}) - assert.Equal(t, updatedAt.In(utcTZ), issueAfter.UpdatedUnix.AsTime().In(utcTZ)) } func TestAPIReplaceIssueLabels(t *testing.T) { diff --git a/tests/integration/api_issue_test.go b/tests/integration/api_issue_test.go index ac26caf006..8e81bb3cb6 100644 --- a/tests/integration/api_issue_test.go +++ b/tests/integration/api_issue_test.go @@ -213,61 +213,155 @@ func TestAPIEditIssue(t *testing.T) { assert.Equal(t, title, issueAfter.Title) } -func TestAPIEditIssueWithAutoDate(t *testing.T) { +func TestAPIEditIssueAutoDate(t *testing.T) { defer tests.PrepareTestEnv(t)() - issueBefore := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: 10}) + issueBefore := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: 13}) repoBefore := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: issueBefore.RepoID}) owner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repoBefore.OwnerID}) assert.NoError(t, issueBefore.LoadAttributes(db.DefaultContext)) - session := loginUser(t, owner.Name) - token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteIssue) + t.Run("WithAutoDate", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() - body := "new content!" - urlStr := fmt.Sprintf("/api/v1/repos/%s/%s/issues/%d?token=%s", owner.Name, repoBefore.Name, issueBefore.Index, token) - req := NewRequestWithJSON(t, "PATCH", urlStr, api.EditIssueOption{ - Body: &body, + // User2 is not owner, but can update the 'public' issue with auto date + session := loginUser(t, "user2") + token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteIssue) + urlStr := fmt.Sprintf("/api/v1/repos/%s/%s/issues/%d?token=%s", owner.Name, repoBefore.Name, issueBefore.Index, token) + + body := "new content!" + req := NewRequestWithJSON(t, "PATCH", urlStr, api.EditIssueOption{ + Body: &body, + }) + resp := MakeRequest(t, req, http.StatusCreated) + var apiIssue api.Issue + DecodeJSON(t, resp, &apiIssue) + + // the execution of the API call supposedly lasted less than one minute + updatedSince := time.Since(apiIssue.Updated) + assert.LessOrEqual(t, updatedSince, time.Minute) + + issueAfter := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: issueBefore.ID}) + updatedSince = time.Since(issueAfter.UpdatedUnix.AsTime()) + assert.LessOrEqual(t, updatedSince, time.Minute) }) - resp := MakeRequest(t, req, http.StatusCreated) - var apiIssue api.Issue - DecodeJSON(t, resp, &apiIssue) - // the execution of the API call supposedly lasted less than one minute - updatedSince := time.Since(apiIssue.Updated) - assert.LessOrEqual(t, updatedSince, time.Minute) + t.Run("WithUpdateDate", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() - issueAfter := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: 10}) - updatedSince = time.Since(issueAfter.UpdatedUnix.AsTime()) - assert.LessOrEqual(t, updatedSince, time.Minute) + // User1 is admin, and so can update the issue without auto date + session := loginUser(t, "user1") + token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteIssue) + urlStr := fmt.Sprintf("/api/v1/repos/%s/%s/issues/%d?token=%s", owner.Name, repoBefore.Name, issueBefore.Index, token) + + body := "new content, with updated time" + updatedAt := time.Now().Add(-time.Hour).Truncate(time.Second) + req := NewRequestWithJSON(t, "PATCH", urlStr, api.EditIssueOption{ + Body: &body, + Updated: &updatedAt, + }) + resp := MakeRequest(t, req, http.StatusCreated) + var apiIssue api.Issue + DecodeJSON(t, resp, &apiIssue) + + // dates are converted into the same tz, in order to compare them + utcTZ, _ := time.LoadLocation("UTC") + assert.Equal(t, updatedAt.In(utcTZ), apiIssue.Updated.In(utcTZ)) + + issueAfter := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: issueBefore.ID}) + assert.Equal(t, updatedAt.In(utcTZ), issueAfter.UpdatedUnix.AsTime().In(utcTZ)) + }) + + t.Run("WithoutPermission", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + // User2 is not owner nor admin, and so can't update the issue without auto date + session := loginUser(t, "user2") + token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteIssue) + urlStr := fmt.Sprintf("/api/v1/repos/%s/%s/issues/%d?token=%s", owner.Name, repoBefore.Name, issueBefore.Index, token) + + body := "new content, with updated time" + updatedAt := time.Now().Add(-time.Hour).Truncate(time.Second) + req := NewRequestWithJSON(t, "PATCH", urlStr, api.EditIssueOption{ + Body: &body, + Updated: &updatedAt, + }) + resp := MakeRequest(t, req, http.StatusForbidden) + var apiError api.APIError + DecodeJSON(t, resp, &apiError) + + assert.Equal(t, "user needs to have admin or owner right", apiError.Message) + }) } -func TestAPIEditIssueWithNoAutoDate(t *testing.T) { +func TestAPIEditIssueMilestoneAutoDate(t *testing.T) { defer tests.PrepareTestEnv(t)() - issueBefore := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: 10}) + issueBefore := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: 1}) repoBefore := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: issueBefore.RepoID}) + owner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repoBefore.OwnerID}) assert.NoError(t, issueBefore.LoadAttributes(db.DefaultContext)) session := loginUser(t, owner.Name) token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteIssue) - - body := "new content, with updated time" - updatedAt := time.Now().Add(-time.Hour).Truncate(time.Second) urlStr := fmt.Sprintf("/api/v1/repos/%s/%s/issues/%d?token=%s", owner.Name, repoBefore.Name, issueBefore.Index, token) - req := NewRequestWithJSON(t, "PATCH", urlStr, api.EditIssueOption{ - Body: &body, - Updated: &updatedAt, + + t.Run("WithAutoDate", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + milestone := int64(1) + req := NewRequestWithJSON(t, "PATCH", urlStr, api.EditIssueOption{ + Milestone: &milestone, + }) + MakeRequest(t, req, http.StatusCreated) + + // the execution of the API call supposedly lasted less than one minute + milestoneAfter := unittest.AssertExistsAndLoadBean(t, &issues_model.Milestone{ID: milestone}) + updatedSince := time.Since(milestoneAfter.UpdatedUnix.AsTime()) + assert.LessOrEqual(t, updatedSince, time.Minute) + }) + + t.Run("WithPostUpdateDate", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + // Note: the updated_unix field of the test Milestones is set to NULL + // Hence, any date is higher than the Milestone's updated date + updatedAt := time.Now().Add(-time.Hour).Truncate(time.Second) + milestone := int64(2) + req := NewRequestWithJSON(t, "PATCH", urlStr, api.EditIssueOption{ + Milestone: &milestone, + Updated: &updatedAt, + }) + MakeRequest(t, req, http.StatusCreated) + + // the milestone date should be set to 'updatedAt' + // dates are converted into the same tz, in order to compare them + utcTZ, _ := time.LoadLocation("UTC") + milestoneAfter := unittest.AssertExistsAndLoadBean(t, &issues_model.Milestone{ID: milestone}) + assert.Equal(t, updatedAt.In(utcTZ), milestoneAfter.UpdatedUnix.AsTime().In(utcTZ)) + }) + + t.Run("WithPastUpdateDate", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + // Note: This Milestone's updated_unix has been set to Now() by the first subtest + milestone := int64(1) + milestoneBefore := unittest.AssertExistsAndLoadBean(t, &issues_model.Milestone{ID: milestone}) + + updatedAt := time.Now().Add(-time.Hour).Truncate(time.Second) + req := NewRequestWithJSON(t, "PATCH", urlStr, api.EditIssueOption{ + Milestone: &milestone, + Updated: &updatedAt, + }) + MakeRequest(t, req, http.StatusCreated) + + // the milestone date should not change + // dates are converted into the same tz, in order to compare them + utcTZ, _ := time.LoadLocation("UTC") + milestoneAfter := unittest.AssertExistsAndLoadBean(t, &issues_model.Milestone{ID: milestone}) + assert.Equal(t, milestoneAfter.UpdatedUnix.AsTime().In(utcTZ), milestoneBefore.UpdatedUnix.AsTime().In(utcTZ)) }) - resp := MakeRequest(t, req, http.StatusCreated) - var apiIssue api.Issue - DecodeJSON(t, resp, &apiIssue) - // dates will be converted into the same tz, in order to compare them - utcTZ, _ := time.LoadLocation("UTC") - assert.Equal(t, updatedAt.In(utcTZ), apiIssue.Updated.In(utcTZ)) - issueAfter := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: 10}) - assert.Equal(t, updatedAt.In(utcTZ), issueAfter.UpdatedUnix.AsTime().In(utcTZ)) } func TestAPISearchIssues(t *testing.T) {