| /* | |
| * Licensed to the Apache Software Foundation (ASF) under one | |
| * or more contributor license agreements. See the NOTICE file | |
| * distributed with this work for additional information | |
| * regarding copyright ownership. The ASF licenses this file | |
| * to you under the Apache License, Version 2.0 (the | |
| * "License"); you may not use this file except in compliance | |
| * with the License. You may obtain a copy of the License at | |
| * | |
| * http://www.apache.org/licenses/LICENSE-2.0 | |
| * | |
| * Unless required by applicable law or agreed to in writing, | |
| * software distributed under the License is distributed on an | |
| * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY | |
| * KIND, either express or implied. See the License for the | |
| * specific language governing permissions and limitations | |
| * under the License. | |
| */ | |
| package cli | |
| import ( | |
| "encoding/json" | |
| "fmt" | |
| "testing" | |
| "github.com/apache/answer/internal/base/constant" | |
| "github.com/apache/answer/internal/entity" | |
| "github.com/apache/answer/internal/schema" | |
| "github.com/apache/answer/plugin" | |
| "github.com/stretchr/testify/assert" | |
| "github.com/stretchr/testify/require" | |
| "xorm.io/xorm" | |
| ) | |
| const ( | |
| testSrc = "https://old.example.com/uploads/" | |
| testDst = "https://new.example.com/uploads/" | |
| ) | |
| func TestClassifyText(t *testing.T) { | |
| tests := []struct { | |
| name string | |
| text string | |
| src, dst string | |
| want prefixMatch | |
| }{ | |
| {"none", "plain text https://old.example.com/uploads/x.png", testSrc, testDst, matchNone}, | |
| {"markdown src", "", testSrc, testDst, matchFix}, | |
| {"html dst", `<img src="https://new.example.com/uploads/a.png">`, testSrc, testDst, matchSkip}, | |
| {"both, disjoint prefixes", "[a](https://old.example.com/uploads/a) [b](https://new.example.com/uploads/b)", testSrc, testDst, matchFix}, | |
| {"dst nested under src is not src", "", "/uploads/", "/uploads/new/", matchSkip}, | |
| {"src nested under dst is not dst", "", "/uploads/new/", "/uploads/", matchFix}, | |
| {"both, dst starts with src", " ", "/uploads/", "/uploads/new/", matchBoth}, | |
| {"both, src starts with dst", " ", "/uploads/new/", "/uploads/", matchFix}, | |
| } | |
| for _, tt := range tests { | |
| t.Run(tt.name, func(t *testing.T) { | |
| assert.Equal(t, tt.want, classifyText(tt.text, tt.src, tt.dst, textURLMarkers)) | |
| }) | |
| } | |
| } | |
| func TestClassifyFields(t *testing.T) { | |
| src := "" | |
| dst := "" | |
| const nestedSrc, nestedDst = "/uploads/", "/uploads/cdn/" | |
| nsrc := "" | |
| ndst := "" | |
| tests := []struct { | |
| name string | |
| fields []string | |
| src, dst string | |
| want prefixMatch | |
| }{ | |
| {"none", []string{"x", "y"}, testSrc, testDst, matchNone}, | |
| {"all dst", []string{dst, dst}, testSrc, testDst, matchSkip}, | |
| {"src and dst in different fields", []string{src, dst}, testSrc, testDst, matchFix}, | |
| {"dst then src", []string{dst, src}, testSrc, testDst, matchFix}, | |
| {"both in one field, disjoint prefixes", []string{src + dst, dst}, testSrc, testDst, matchFix}, | |
| {"both in one field, nested prefixes", []string{nsrc + ndst, nsrc}, nestedSrc, nestedDst, matchBoth}, | |
| {"both in a later field, nested prefixes", []string{nsrc, ndst + nsrc}, nestedSrc, nestedDst, matchBoth}, | |
| {"nested prefixes across fields", []string{nsrc, ndst}, nestedSrc, nestedDst, matchFix}, | |
| } | |
| for _, tt := range tests { | |
| t.Run(tt.name, func(t *testing.T) { | |
| assert.Equal(t, tt.want, classifyFields(tt.fields, tt.src, tt.dst, textURLMarkers)) | |
| }) | |
| } | |
| } | |
| func TestClassifyValue(t *testing.T) { | |
| assert.Equal(t, matchFix, classifyValue(testSrc+"a.png", testSrc, testDst)) | |
| assert.Equal(t, matchSkip, classifyValue(testDst+"a.png", testSrc, testDst)) | |
| assert.Equal(t, matchNone, classifyValue("https://other/a.png", testSrc, testDst)) | |
| assert.Equal(t, matchSkip, classifyValue("/uploads/new/a.png", "/uploads/", "/uploads/new/")) | |
| assert.Equal(t, matchFix, classifyValue("/uploads/new/a.png", "/uploads/new/", "/uploads/")) | |
| } | |
| func newFixTestDB(t *testing.T) *xorm.Engine { | |
| t.Helper() | |
| x, err := xorm.NewEngine("sqlite", ":memory:") | |
| require.NoError(t, err) | |
| t.Cleanup(func() { _ = x.Close() }) | |
| require.NoError(t, x.Sync(new(entity.Question), new(entity.Answer), new(entity.Tag), new(entity.Comment), | |
| new(entity.Revision), new(entity.FileRecord), new(entity.User), new(entity.SiteInfo), new(entity.Notification))) | |
| return x | |
| } | |
| func TestFixPost(t *testing.T) { | |
| x := newFixTestDB(t) | |
| opts := &FixURLPrefixOptions{SrcPrefix: testSrc, DstPrefix: testDst} | |
| // Prefixes mixed across fields are fixed field by field. | |
| _, err := x.Insert(&entity.Question{ID: "1", | |
| OriginalText: "", | |
| ParsedText: `<img src="` + testDst + `q.png">`}) | |
| require.NoError(t, err) | |
| // Both prefixes in one field are fixed, since the prefixes do not overlap. | |
| _, err = x.Insert(&entity.Answer{ID: "5", | |
| OriginalText: " ", | |
| ParsedText: `<img src="` + testSrc + `a.png">`}) | |
| require.NoError(t, err) | |
| _, err = x.Insert(&entity.Tag{ID: "2", SlugName: "go", | |
| OriginalText: "", | |
| ParsedText: `<img src="` + testSrc + `t.png">`}) | |
| require.NoError(t, err) | |
| tagObjectType := constant.ObjectTypeStrMapping[constant.TagObjectType] | |
| migratable, _ := json.Marshal(map[string]any{ | |
| "ID": "2", "OriginalText": "", "ParsedText": `<img src="` + testSrc + `t.png">`}) | |
| mixed, _ := json.Marshal(map[string]any{ | |
| "ID": "3", "OriginalText": " "}) | |
| _, err = x.Insert(&entity.Revision{ID: "11", ObjectType: tagObjectType, ObjectID: "2", Content: string(migratable)}) | |
| require.NoError(t, err) | |
| _, err = x.Insert(&entity.Revision{ID: "12", ObjectType: tagObjectType, ObjectID: "3", Content: string(mixed)}) | |
| require.NoError(t, err) | |
| _, err = x.Insert(&entity.FileRecord{ID: 1, FileURL: testSrc + "t.png", ObjectID: "0", Status: entity.FileRecordStatusAvailable, Source: string(plugin.UserPost)}) | |
| require.NoError(t, err) | |
| _, err = x.Insert(&entity.FileRecord{ID: 2, FileURL: testSrc + "keep.png", ObjectID: "0", Status: entity.FileRecordStatusAvailable, Source: string(plugin.UserPost)}) | |
| require.NoError(t, err) | |
| // Linked records are not checked against revisions. | |
| _, err = x.Insert(&entity.FileRecord{ID: 3, FileURL: testSrc + "linked.png", ObjectID: "100", Source: string(plugin.UserPost)}) | |
| require.NoError(t, err) | |
| // Deleted records are not checked against revisions either. | |
| _, err = x.Insert(&entity.FileRecord{ID: 4, FileURL: testSrc + "deleted.png", ObjectID: "0", | |
| Status: entity.FileRecordStatusDeleted, Source: string(plugin.UserPost)}) | |
| require.NoError(t, err) | |
| rows, files, err := fixPost(x, opts, newFixRunState(0)) | |
| require.NoError(t, err) | |
| assert.Equal(t, int64(4), rows.Fixed) // question 1, answer 5, tag 2 with revision 11, orphan revision 12 | |
| assert.Equal(t, int64(0), rows.Skipped) | |
| assert.Equal(t, int64(4), files.Fixed) // file_record 1 to 4 | |
| assert.Equal(t, int64(0), files.Skipped) | |
| q := &entity.Question{} | |
| _, err = x.ID("1").Get(q) | |
| require.NoError(t, err) | |
| assert.Equal(t, "", q.OriginalText) | |
| assert.Equal(t, `<img src="`+testDst+`q.png">`, q.ParsedText) | |
| a := &entity.Answer{} | |
| _, err = x.ID("5").Get(a) | |
| require.NoError(t, err) | |
| assert.Equal(t, " ", a.OriginalText) | |
| assert.Equal(t, `<img src="`+testDst+`a.png">`, a.ParsedText) | |
| tag := &entity.Tag{} | |
| _, err = x.ID("2").Get(tag) | |
| require.NoError(t, err) | |
| assert.Equal(t, "", tag.OriginalText) | |
| assert.Equal(t, `<img src="`+testDst+`t.png">`, tag.ParsedText) | |
| rev := &entity.Revision{} | |
| _, err = x.ID("11").Get(rev) | |
| require.NoError(t, err) | |
| var doc map[string]any | |
| require.NoError(t, json.Unmarshal([]byte(rev.Content), &doc)) | |
| assert.Equal(t, "2", doc["ID"]) | |
| assert.Equal(t, "", doc["OriginalText"]) | |
| assert.Equal(t, `<img src="`+testDst+`t.png">`, doc["ParsedText"]) | |
| rev = &entity.Revision{} | |
| _, err = x.ID("12").Get(rev) | |
| require.NoError(t, err) | |
| doc = map[string]any{} | |
| require.NoError(t, json.Unmarshal([]byte(rev.Content), &doc)) | |
| assert.Equal(t, " ", doc["OriginalText"]) | |
| assertFileURL(t, x, 1, testDst+"t.png") | |
| // Referenced by the fixed mixed revision, so it follows. | |
| assertFileURL(t, x, 2, testDst+"keep.png") | |
| assertFileURL(t, x, 3, testDst+"linked.png") | |
| assertFileURL(t, x, 4, testDst+"deleted.png") | |
| } | |
| // assertFileURL asserts the file_url of file record id. | |
| func assertFileURL(t *testing.T, x *xorm.Engine, id int, want string) { | |
| t.Helper() | |
| fr := &entity.FileRecord{} | |
| _, err := x.ID(id).Get(fr) | |
| require.NoError(t, err) | |
| assert.Equal(t, want, fr.FileURL) | |
| } | |
| func TestFixAvatarLimitKeepsFileRecordsInSync(t *testing.T) { | |
| x := newFixTestDB(t) | |
| opts := &FixURLPrefixOptions{SrcPrefix: testSrc, DstPrefix: testDst} | |
| for i, name := range []string{"a.png", "b.png"} { | |
| _, err := x.Insert(&entity.User{ID: fmt.Sprint(i + 1), Username: name, | |
| Avatar: `{"type":"custom","custom":"` + testSrc + name + `"}`}) | |
| require.NoError(t, err) | |
| _, err = x.Insert(&entity.FileRecord{ID: i + 1, FileURL: testSrc + name, Source: string(plugin.UserAvatar)}) | |
| require.NoError(t, err) | |
| } | |
| result, err := fixAvatar(x, opts, newFixRunState(1)) | |
| require.NoError(t, err) | |
| assert.Equal(t, int64(1), result.Fixed) | |
| // Only the migrated user's file record follows; the other stays on the old URL. | |
| assertFileURL(t, x, 1, testDst+"a.png") | |
| assertFileURL(t, x, 2, testSrc+"b.png") | |
| } | |
| func TestFixAvatarRepairsStaleFileRecord(t *testing.T) { | |
| x := newFixTestDB(t) | |
| opts := &FixURLPrefixOptions{SrcPrefix: testSrc, DstPrefix: testDst} | |
| // The avatar was already moved (e.g. by hand), but its file record was not. | |
| _, err := x.Insert(&entity.User{ID: "1", Username: "u1", | |
| Avatar: `{"type":"custom","custom":"` + testDst + `a.png"}`}) | |
| require.NoError(t, err) | |
| _, err = x.Insert(&entity.FileRecord{ID: 1, FileURL: testSrc + "a.png", Source: string(plugin.UserAvatar)}) | |
| require.NoError(t, err) | |
| _, err = fixAvatar(x, &FixURLPrefixOptions{SrcPrefix: testSrc, DstPrefix: testDst, DryRun: true}, newFixRunState(0)) | |
| require.NoError(t, err) | |
| assertFileURL(t, x, 1, testSrc+"a.png") | |
| result, err := fixAvatar(x, opts, newFixRunState(0)) | |
| require.NoError(t, err) | |
| assert.Equal(t, int64(0), result.Fixed) | |
| assert.Equal(t, int64(1), result.Skipped) | |
| assertFileURL(t, x, 1, testDst+"a.png") | |
| } | |
| func TestFixBrandingLimitKeepsFileRecordsInSync(t *testing.T) { | |
| x := newFixTestDB(t) | |
| opts := &FixURLPrefixOptions{SrcPrefix: testSrc, DstPrefix: testDst} | |
| // logo and mobile_logo share one file record. | |
| _, err := x.Insert(&entity.SiteInfo{Type: constant.SiteTypeBranding, | |
| Content: `{"logo":"` + testSrc + `logo.png","mobile_logo":"` + testSrc + `logo.png","favicon":"` + testSrc + `favicon.png"}`}) | |
| require.NoError(t, err) | |
| _, err = x.Insert(&entity.FileRecord{ID: 1, FileURL: testSrc + "logo.png", Source: string(plugin.AdminBranding)}) | |
| require.NoError(t, err) | |
| _, err = x.Insert(&entity.FileRecord{ID: 2, FileURL: testSrc + "favicon.png", Source: string(plugin.AdminBranding)}) | |
| require.NoError(t, err) | |
| result, err := fixBranding(x, opts, newFixRunState(1)) | |
| require.NoError(t, err) | |
| assert.Equal(t, int64(1), result.Fixed) | |
| // mobile_logo still uses the old URL, so the shared record must keep it. | |
| assertFileURL(t, x, 1, testSrc+"logo.png") | |
| assertFileURL(t, x, 2, testSrc+"favicon.png") | |
| result, err = fixBranding(x, opts, newFixRunState(1)) | |
| require.NoError(t, err) | |
| assert.Equal(t, int64(1), result.Fixed) | |
| assertFileURL(t, x, 1, testDst+"logo.png") | |
| assertFileURL(t, x, 2, testSrc+"favicon.png") | |
| } | |
| func TestFixPostDryRunMatchesRealRun(t *testing.T) { | |
| x := newFixTestDB(t) | |
| content, _ := json.Marshal(map[string]any{"ID": "1", "OriginalText": ""}) | |
| _, err := x.Insert(&entity.Revision{ID: "11", ObjectType: constant.ObjectTypeStrMapping[constant.QuestionObjectType], | |
| ObjectID: "1", Content: string(content)}) | |
| require.NoError(t, err) | |
| _, err = x.Insert(&entity.FileRecord{ID: 1, FileURL: testSrc + "a.png", ObjectID: "0", Status: entity.FileRecordStatusAvailable, Source: string(plugin.UserPost)}) | |
| require.NoError(t, err) | |
| // A revision mixing disjoint prefixes is fixed, so a dry run must count its file record as referenced. | |
| mixed, _ := json.Marshal(map[string]any{ | |
| "ID": "2", "OriginalText": " "}) | |
| _, err = x.Insert(&entity.Revision{ID: "12", ObjectType: constant.ObjectTypeStrMapping[constant.QuestionObjectType], | |
| ObjectID: "2", Content: string(mixed)}) | |
| require.NoError(t, err) | |
| _, err = x.Insert(&entity.FileRecord{ID: 2, FileURL: testSrc + "b.png", ObjectID: "0", Status: entity.FileRecordStatusAvailable, Source: string(plugin.UserPost)}) | |
| require.NoError(t, err) | |
| // d.png is a bare URL, so fixing the revision's other link does not rewrite it. | |
| bare, _ := json.Marshal(map[string]any{ | |
| "ID": "3", "OriginalText": " " + testSrc + "d.png"}) | |
| _, err = x.Insert(&entity.Revision{ID: "13", ObjectType: constant.ObjectTypeStrMapping[constant.QuestionObjectType], | |
| ObjectID: "3", Content: string(bare)}) | |
| require.NoError(t, err) | |
| _, err = x.Insert(&entity.FileRecord{ID: 3, FileURL: testSrc + "d.png", ObjectID: "0", Status: entity.FileRecordStatusAvailable, Source: string(plugin.UserPost)}) | |
| require.NoError(t, err) | |
| dryRows, dryFiles, err := fixPost(x, &FixURLPrefixOptions{SrcPrefix: testSrc, DstPrefix: testDst, DryRun: true}, newFixRunState(0)) | |
| require.NoError(t, err) | |
| assertFileURL(t, x, 1, testSrc+"a.png") | |
| rows, files, err := fixPost(x, &FixURLPrefixOptions{SrcPrefix: testSrc, DstPrefix: testDst}, newFixRunState(0)) | |
| require.NoError(t, err) | |
| assert.Equal(t, rows.Affected, dryRows.Affected) | |
| assert.Equal(t, rows.Skipped, dryRows.Skipped) | |
| assert.Equal(t, files.Affected, dryFiles.Affected) | |
| assert.Equal(t, files.Skipped, dryFiles.Skipped) | |
| assertFileURL(t, x, 1, testDst+"a.png") | |
| assertFileURL(t, x, 2, testDst+"b.png") | |
| assertFileURL(t, x, 3, testSrc+"d.png") | |
| } | |
| func TestFixPostSkipsBothWithOverlappingPrefixes(t *testing.T) { | |
| x := newFixTestDB(t) | |
| const src, dst = "/uploads/", "/uploads/cdn/" | |
| // Rewriting src here would also turn /uploads/cdn/ into /uploads/cdn/cdn/. | |
| mixedText := " " | |
| _, err := x.Insert(&entity.Answer{ID: "5", OriginalText: mixedText, ParsedText: `<img src="` + src + `a.png">`}) | |
| require.NoError(t, err) | |
| mixed, _ := json.Marshal(map[string]any{"ID": "5", "OriginalText": mixedText}) | |
| _, err = x.Insert(&entity.Revision{ID: "12", ObjectType: constant.ObjectTypeStrMapping[constant.AnswerObjectType], | |
| ObjectID: "5", Content: string(mixed)}) | |
| require.NoError(t, err) | |
| // Referenced only by the skipped revision, so it must keep the old URL. | |
| _, err = x.Insert(&entity.FileRecord{ID: 1, FileURL: src + "a.png", ObjectID: "0", Status: entity.FileRecordStatusAvailable, Source: string(plugin.UserPost)}) | |
| require.NoError(t, err) | |
| dryRows, dryFiles, err := fixPost(x, &FixURLPrefixOptions{SrcPrefix: src, DstPrefix: dst, DryRun: true}, newFixRunState(0)) | |
| require.NoError(t, err) | |
| rows, files, err := fixPost(x, &FixURLPrefixOptions{SrcPrefix: src, DstPrefix: dst}, newFixRunState(0)) | |
| require.NoError(t, err) | |
| assert.Equal(t, fixResult{Skipped: 1}, rows) // answer 5 with revision 12 | |
| assert.Equal(t, fixResult{Skipped: 1}, files) | |
| assert.Equal(t, rows, dryRows) | |
| assert.Equal(t, files, dryFiles) | |
| a := &entity.Answer{} | |
| _, err = x.ID("5").Get(a) | |
| require.NoError(t, err) | |
| assert.Equal(t, mixedText, a.OriginalText) | |
| rev := &entity.Revision{} | |
| _, err = x.ID("12").Get(rev) | |
| require.NoError(t, err) | |
| assert.Equal(t, string(mixed), rev.Content) | |
| assertFileURL(t, x, 1, src+"a.png") | |
| } | |
| func TestFixAvatarSkipsInvalidJSON(t *testing.T) { | |
| x := newFixTestDB(t) | |
| opts := &FixURLPrefixOptions{SrcPrefix: testSrc, DstPrefix: testDst} | |
| // Matches the custom-avatar query but cannot be parsed. | |
| invalid := `{"custom":"` + testSrc + `a.png"` | |
| _, err := x.Insert(&entity.User{ID: "1", Username: "u1", Avatar: invalid}) | |
| require.NoError(t, err) | |
| // A valid row after the invalid one must still be fixed. | |
| _, err = x.Insert(&entity.User{ID: "2", Username: "u2", | |
| Avatar: `{"type":"custom","custom":"` + testSrc + `b.png"}`}) | |
| require.NoError(t, err) | |
| result, err := fixAvatar(x, opts, newFixRunState(0)) | |
| require.NoError(t, err) | |
| assert.Equal(t, int64(1), result.Fixed) | |
| assert.Equal(t, int64(1), result.Skipped) | |
| u := &entity.User{} | |
| _, err = x.ID("1").Get(u) | |
| require.NoError(t, err) | |
| assert.Equal(t, invalid, u.Avatar) | |
| u = &entity.User{} | |
| _, err = x.ID("2").Get(u) | |
| require.NoError(t, err) | |
| // Stored in the same format the app writes. | |
| assert.Equal(t, schema.CustomAvatar(testDst+"b.png").ToJsonString(), u.Avatar) | |
| } | |
| func TestFixPostMixedFieldsWithNestedDst(t *testing.T) { | |
| x := newFixTestDB(t) | |
| opts := &FixURLPrefixOptions{SrcPrefix: "/uploads/", DstPrefix: "/uploads/cdn/"} | |
| // parsed_text is already on DST, which starts with SRC; it must not be rewritten again. | |
| _, err := x.Insert(&entity.Question{ID: "1", | |
| OriginalText: "", | |
| ParsedText: `<img src="/uploads/cdn/q.png">`}) | |
| require.NoError(t, err) | |
| rows, _, err := fixPost(x, opts, newFixRunState(0)) | |
| require.NoError(t, err) | |
| assert.Equal(t, int64(1), rows.Fixed) | |
| q := &entity.Question{} | |
| _, err = x.ID("1").Get(q) | |
| require.NoError(t, err) | |
| assert.Equal(t, "", q.OriginalText) | |
| assert.Equal(t, `<img src="/uploads/cdn/q.png">`, q.ParsedText) | |
| } | |
| func TestFixPostLimitStillSyncsFileRecords(t *testing.T) { | |
| x := newFixTestDB(t) | |
| opts := &FixURLPrefixOptions{SrcPrefix: testSrc, DstPrefix: testDst, Limit: 1} | |
| content, _ := json.Marshal(map[string]any{"ID": "1", "OriginalText": ""}) | |
| _, err := x.Insert(&entity.Revision{ID: "11", ObjectType: constant.ObjectTypeStrMapping[constant.QuestionObjectType], | |
| ObjectID: "1", Content: string(content)}) | |
| require.NoError(t, err) | |
| _, err = x.Insert(&entity.FileRecord{ID: 1, FileURL: testSrc + "a.png", ObjectID: "0", Status: entity.FileRecordStatusAvailable, Source: string(plugin.UserPost)}) | |
| require.NoError(t, err) | |
| state := newFixRunState(opts.Limit) | |
| rows, files, err := fixPost(x, opts, state) | |
| require.NoError(t, err) | |
| assert.True(t, state.exhausted()) | |
| // The file record is reported apart from the rows --limit counts. | |
| assert.Equal(t, int64(1), rows.Affected) | |
| assert.Equal(t, int64(1), files.Affected) | |
| assertFileURL(t, x, 1, testDst+"a.png") | |
| } | |
| // insertQuestionWithRevision inserts a question and one revision of it, both | |
| // linking name under prefix. | |
| func insertQuestionWithRevision(t *testing.T, x *xorm.Engine, id, revID, prefix, name string) { | |
| t.Helper() | |
| _, err := x.Insert(&entity.Question{ID: id, | |
| OriginalText: "", | |
| ParsedText: `<img src="` + prefix + name + `">`}) | |
| require.NoError(t, err) | |
| content, _ := json.Marshal(map[string]any{"ID": id, "OriginalText": ""}) | |
| _, err = x.Insert(&entity.Revision{ID: revID, ObjectType: constant.ObjectTypeStrMapping[constant.QuestionObjectType], | |
| ObjectID: id, Content: string(content)}) | |
| require.NoError(t, err) | |
| } | |
| // assertRevisionHas asserts that the content of revision id contains want. | |
| func assertRevisionHas(t *testing.T, x *xorm.Engine, id, want string) { | |
| t.Helper() | |
| rev := &entity.Revision{} | |
| _, err := x.ID(id).Get(rev) | |
| require.NoError(t, err) | |
| assert.Contains(t, rev.Content, want) | |
| } | |
| func TestFixPostLimitFixesPostWithItsRevisions(t *testing.T) { | |
| x := newFixTestDB(t) | |
| opts := &FixURLPrefixOptions{SrcPrefix: testSrc, DstPrefix: testDst, Limit: 1} | |
| insertQuestionWithRevision(t, x, "1", "11", testSrc, "a.png") | |
| insertQuestionWithRevision(t, x, "2", "12", testSrc, "b.png") | |
| rows, _, err := fixPost(x, opts, newFixRunState(opts.Limit)) | |
| require.NoError(t, err) | |
| assert.Equal(t, fixResult{Affected: 1, Fixed: 1}, rows) | |
| // The post within the limit is fixed with its revision; the other keeps both on SRC. | |
| q := &entity.Question{} | |
| _, err = x.ID("1").Get(q) | |
| require.NoError(t, err) | |
| assert.Equal(t, "", q.OriginalText) | |
| assertRevisionHas(t, x, "11", "]("+testDst+"a.png)") | |
| q = &entity.Question{} | |
| _, err = x.ID("2").Get(q) | |
| require.NoError(t, err) | |
| assert.Equal(t, "", q.OriginalText) | |
| assertRevisionHas(t, x, "12", "]("+testSrc+"b.png)") | |
| } | |
| func TestFixPostFixesOldRevisionsOfPostOnDst(t *testing.T) { | |
| x := newFixTestDB(t) | |
| opts := &FixURLPrefixOptions{SrcPrefix: testSrc, DstPrefix: testDst} | |
| // The post is already on DST, but one of its revisions is not. | |
| insertQuestionWithRevision(t, x, "1", "11", testDst, "a.png") | |
| old, _ := json.Marshal(map[string]any{"ID": "1", "OriginalText": ""}) | |
| _, err := x.Insert(&entity.Revision{ID: "10", ObjectType: constant.ObjectTypeStrMapping[constant.QuestionObjectType], | |
| ObjectID: "1", Content: string(old)}) | |
| require.NoError(t, err) | |
| rows, _, err := fixPost(x, opts, newFixRunState(0)) | |
| require.NoError(t, err) | |
| assert.Equal(t, fixResult{Affected: 1, Fixed: 1}, rows) | |
| assertRevisionHas(t, x, "10", "]("+testDst+"old.png)") | |
| assertRevisionHas(t, x, "11", "]("+testDst+"a.png)") | |
| } | |
| func TestFixPostPagesByID(t *testing.T) { | |
| x := newFixTestDB(t) | |
| opts := &FixURLPrefixOptions{SrcPrefix: testSrc, DstPrefix: testDst} | |
| // More than two pages; ids 1..n also order differently as strings ("10" < "9"). | |
| n := 2*fixBatchSize + 1 | |
| for i := 1; i <= n; i++ { | |
| insertQuestionWithRevision(t, x, fmt.Sprint(i), fmt.Sprint(1000+i), testSrc, fmt.Sprintf("%d.png", i)) | |
| } | |
| rows, _, err := fixPost(x, opts, newFixRunState(0)) | |
| require.NoError(t, err) | |
| assert.Equal(t, fixResult{Affected: int64(n), Fixed: int64(n)}, rows) | |
| count, err := x.Where("original_text LIKE ?", "%"+testSrc+"%").Count(new(entity.Question)) | |
| require.NoError(t, err) | |
| assert.Zero(t, count) | |
| count, err = x.Where("content LIKE ?", "%"+testSrc+"%").Count(new(entity.Revision)) | |
| require.NoError(t, err) | |
| assert.Zero(t, count) | |
| } | |
| func TestFixAvatarFixesCustomURLOfOtherType(t *testing.T) { | |
| x := newFixTestDB(t) | |
| opts := &FixURLPrefixOptions{SrcPrefix: testSrc, DstPrefix: testDst} | |
| // The user switched to gravatar; the old custom URL is kept for switching back. | |
| _, err := x.Insert(&entity.User{ID: "1", Username: "u1", | |
| Avatar: `{"type":"gravatar","gravatar":"https://gravatar.example/x","custom":"` + testSrc + `a.png"}`}) | |
| require.NoError(t, err) | |
| _, err = x.Insert(&entity.FileRecord{ID: 1, FileURL: testSrc + "a.png", Source: string(plugin.UserAvatar)}) | |
| require.NoError(t, err) | |
| result, err := fixAvatar(x, opts, newFixRunState(0)) | |
| require.NoError(t, err) | |
| assert.Equal(t, int64(1), result.Fixed) | |
| u := &entity.User{} | |
| _, err = x.ID("1").Get(u) | |
| require.NoError(t, err) | |
| var avatar schema.AvatarInfo | |
| require.NoError(t, json.Unmarshal([]byte(u.Avatar), &avatar)) | |
| assert.Equal(t, schema.AvatarInfo{Type: "gravatar", Gravatar: "https://gravatar.example/x", Custom: testDst + "a.png"}, avatar) | |
| assertFileURL(t, x, 1, testDst+"a.png") | |
| } | |
| func TestFixPostFixesComments(t *testing.T) { | |
| x := newFixTestDB(t) | |
| opts := &FixURLPrefixOptions{SrcPrefix: testSrc, DstPrefix: testDst} | |
| _, err := x.Insert(&entity.Comment{ID: "1", UserID: "1", ObjectID: "1", | |
| OriginalText: "see ", | |
| ParsedText: `<p>see <img src="` + testSrc + `c.png"></p>`}) | |
| require.NoError(t, err) | |
| rows, _, err := fixPost(x, opts, newFixRunState(0)) | |
| require.NoError(t, err) | |
| assert.Equal(t, fixResult{Affected: 1, Fixed: 1}, rows) | |
| c := &entity.Comment{} | |
| _, err = x.ID("1").Get(c) | |
| require.NoError(t, err) | |
| assert.Equal(t, "see ", c.OriginalText) | |
| assert.Equal(t, `<p>see <img src="`+testDst+`c.png"></p>`, c.ParsedText) | |
| } | |
| func TestFixAvatarFixesNotificationAvatars(t *testing.T) { | |
| x := newFixTestDB(t) | |
| opts := &FixURLPrefixOptions{SrcPrefix: testSrc, DstPrefix: testDst} | |
| // Encoded the way the app stores notification content. | |
| notificationContent := func(avatar string) string { | |
| content, _ := json.Marshal(&schema.NotificationContent{ | |
| UserInfo: &schema.UserBasicInfo{ID: "7", Username: "u7", Avatar: avatar}, | |
| ObjectInfo: schema.ObjectInfo{Title: "q", ObjectID: "1", ObjectType: "question"}, | |
| NotificationAction: "notification.action.answered_question", | |
| }) | |
| return string(content) | |
| } | |
| for id, avatar := range map[string]string{ | |
| "1": testSrc + "a.png", | |
| "2": testDst + "a.png", | |
| "3": "https://gravatar.example/x", | |
| } { | |
| _, err := x.Insert(&entity.Notification{ID: id, UserID: "8", ObjectID: "1", Content: notificationContent(avatar)}) | |
| require.NoError(t, err) | |
| } | |
| result, err := fixAvatar(x, opts, newFixRunState(0)) | |
| require.NoError(t, err) | |
| assert.Equal(t, fixResult{Affected: 1, Fixed: 1}, result) | |
| for id, want := range map[string]string{ | |
| "1": notificationContent(testDst + "a.png"), | |
| "2": notificationContent(testDst + "a.png"), | |
| "3": notificationContent("https://gravatar.example/x"), | |
| } { | |
| n := &entity.Notification{} | |
| _, err = x.ID(id).Get(n) | |
| require.NoError(t, err) | |
| assert.Equal(t, want, n.Content, "notification %s", id) | |
| } | |
| } | |
| func TestFixPostRollsBackUnitOnFailedWrite(t *testing.T) { | |
| x := newFixTestDB(t) | |
| opts := &FixURLPrefixOptions{SrcPrefix: testSrc, DstPrefix: testDst} | |
| insertQuestionWithRevision(t, x, "1", "11", testSrc, "a.png") | |
| // The post row is written first; failing its revision must undo that write. | |
| _, err := x.Exec("CREATE TRIGGER fail_revision BEFORE UPDATE ON revision BEGIN SELECT RAISE(ABORT, 'boom'); END") | |
| require.NoError(t, err) | |
| _, _, err = fixPost(x, opts, newFixRunState(0)) | |
| require.ErrorContains(t, err, "boom") | |
| q := &entity.Question{} | |
| _, err = x.ID("1").Get(q) | |
| require.NoError(t, err) | |
| assert.Equal(t, "", q.OriginalText) | |
| assertRevisionHas(t, x, "11", "]("+testSrc+"a.png)") | |
| } | |
| func TestFixPostMixedFieldWhenSrcStartsWithDst(t *testing.T) { | |
| x := newFixTestDB(t) | |
| opts := &FixURLPrefixOptions{SrcPrefix: "/uploads/cdn/", DstPrefix: "/uploads/"} | |
| // Replacing /uploads/cdn/ cannot touch a URL that is only /uploads/. | |
| _, err := x.Insert(&entity.Answer{ID: "5", | |
| OriginalText: " ", | |
| ParsedText: `<img src="/uploads/cdn/a.png"><img src="/uploads/b.png">`}) | |
| require.NoError(t, err) | |
| rows, _, err := fixPost(x, opts, newFixRunState(0)) | |
| require.NoError(t, err) | |
| assert.Equal(t, fixResult{Affected: 1, Fixed: 1}, rows) | |
| a := &entity.Answer{} | |
| _, err = x.ID("5").Get(a) | |
| require.NoError(t, err) | |
| assert.Equal(t, " ", a.OriginalText) | |
| assert.Equal(t, `<img src="/uploads/a.png"><img src="/uploads/b.png">`, a.ParsedText) | |
| } | |
| func TestFixURLPrefixRejectsMismatchedTrailingSlash(t *testing.T) { | |
| err := FixURLPrefix(nil, &FixURLPrefixOptions{FixType: fixTypeAll, SrcPrefix: "/uploads/", DstPrefix: "/cdn"}) | |
| require.Error(t, err) | |
| assert.Contains(t, err.Error(), `end with "/"`) | |
| } | |
| func TestFixURLPrefixRejectsNegativeLimit(t *testing.T) { | |
| err := FixURLPrefix(nil, &FixURLPrefixOptions{FixType: fixTypeAll, SrcPrefix: testSrc, DstPrefix: testDst, Limit: -1}) | |
| require.Error(t, err) | |
| assert.Contains(t, err.Error(), "limit must not be negative") | |
| } | |
| func TestFixURLPrefixRejectsUnknownTypeBeforeConnecting(t *testing.T) { | |
| // A nil database config would fail to connect, so this only passes if the type is checked first. | |
| err := FixURLPrefix(nil, &FixURLPrefixOptions{FixType: "avtar", SrcPrefix: testSrc, DstPrefix: testDst}) | |
| require.Error(t, err) | |
| assert.Contains(t, err.Error(), "unknown fix type") | |
| } |