// Vikunja is a to-do list application to facilitate your life. // Copyright 2018-present Vikunja and contributors. All rights reserved. // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU Affero General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU Affero General Public License for more details. // // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . package models import ( "testing" "regexp" "code.vikunja.io/api/pkg/db" "code.vikunja.io/api/pkg/events" "code.vikunja.io/api/pkg/notifications" "code.vikunja.io/api/pkg/user" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func TestFindMentionedUsersInText(t *testing.T) { user1 := &user.User{ ID: 1, } user2 := &user.User{ ID: 2, } tests := []struct { name string text string wantUsers []*user.User wantErr bool }{ { name: "no users mentioned", text: "

Lorem Ipsum dolor sit amet

", }, { name: "one user at the beginning", text: `

@user1 Lorem Ipsum

`, wantUsers: []*user.User{user1}, }, { name: "one user at the end", text: `

Lorem Ipsum @user1

`, wantUsers: []*user.User{user1}, }, { name: "one user in the middle", text: `

Lorem @user1 Ipsum

`, wantUsers: []*user.User{user1}, }, { name: "same user multiple times", text: `

Lorem @user1 Ipsum @user1 @user1

`, wantUsers: []*user.User{user1}, }, { name: "Multiple users", text: `

Lorem @user1 Ipsum @user2

`, wantUsers: []*user.User{user1, user2}, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { db.LoadAndAssertFixtures(t) s := db.NewSession() defer s.Close() gotUsers, err := FindMentionedUsersInText(s, tt.text) if (err != nil) != tt.wantErr { t.Errorf("FindMentionedUsersInText() error = %v, wantErr %v", err, tt.wantErr) return } for _, u := range tt.wantUsers { _, has := gotUsers[u.ID] if !has { t.Errorf("wanted user %d but did not get it", u.ID) } } }) } } func TestHandleMentionsWithNilTask(t *testing.T) { t.Run("HandleTaskUpdatedMentions should not panic with nil task", func(t *testing.T) { events.TestListener(t, &TaskUpdatedEvent{Task: nil, Doer: nil}, &HandleTaskUpdatedMentions{}) }) t.Run("HandleTaskCreateMentions should not panic with nil task", func(t *testing.T) { events.TestListener(t, &TaskCreatedEvent{Task: nil, Doer: nil}, &HandleTaskCreateMentions{}) }) t.Run("HandleTaskCommentEditMentions should not panic with nil task", func(t *testing.T) { events.TestListener(t, &TaskCommentUpdatedEvent{Task: nil, Comment: nil, Doer: nil}, &HandleTaskCommentEditMentions{}) }) } func TestSendingMentionNotification(t *testing.T) { u := &user.User{ID: 1} t.Run("should send notifications to all users having access", func(t *testing.T) { db.LoadAndAssertFixtures(t) s := db.NewSession() defer s.Close() task, err := GetTaskByIDSimple(s, 32) require.NoError(t, err) project, err := GetProjectSimpleByID(s, task.ProjectID) require.NoError(t, err) tc := &TaskComment{ Comment: `

Lorem Ipsum @user1 @user2 @user3 @user4 @user5 @user6

`, TaskID: 32, // user2 has access to the project that task belongs to } err = tc.Create(s, u) require.NoError(t, err) n := &TaskCommentNotification{ Doer: u, Task: &task, Comment: tc, Project: project, } _, err = notifyMentionedUsers(s, &task, tc.Comment, n) require.NoError(t, err) require.NoError(t, s.Commit()) db.AssertExists(t, "notifications", map[string]interface{}{ "subject_id": tc.ID, "notifiable_id": 1, "name": n.Name(), }, false) db.AssertExists(t, "notifications", map[string]interface{}{ "subject_id": tc.ID, "notifiable_id": 2, "name": n.Name(), }, false) db.AssertExists(t, "notifications", map[string]interface{}{ "subject_id": tc.ID, "notifiable_id": 3, "name": n.Name(), }, false) db.AssertMissing(t, "notifications", map[string]interface{}{ "subject_id": tc.ID, "notifiable_id": 4, "name": n.Name(), }) db.AssertMissing(t, "notifications", map[string]interface{}{ "subject_id": tc.ID, "notifiable_id": 5, "name": n.Name(), }) db.AssertMissing(t, "notifications", map[string]interface{}{ "subject_id": tc.ID, "notifiable_id": 6, "name": n.Name(), }) }) t.Run("should not send notifications multiple times", func(t *testing.T) { db.LoadAndAssertFixtures(t) s := db.NewSession() defer s.Close() task, err := GetTaskByIDSimple(s, 32) require.NoError(t, err) project, err := GetProjectSimpleByID(s, task.ProjectID) require.NoError(t, err) tc := &TaskComment{ Comment: `

Lorem Ipsum @user2

`, TaskID: 32, // user2 has access to the project that task belongs to } err = tc.Create(s, u) require.NoError(t, err) n := &TaskCommentNotification{ Doer: u, Task: &task, Comment: tc, Project: project, } _, err = notifyMentionedUsers(s, &task, tc.Comment, n) require.NoError(t, err) _, err = notifyMentionedUsers(s, &task, `

Lorem Ipsum @user2 @user3

`, n) require.NoError(t, err) // The second time mentioning the user in the same task should not create another notification dbNotifications, err := notifications.GetNotificationsForNameAndUser(s, 2, n.Name(), tc.ID) require.NoError(t, err) assert.Len(t, dbNotifications, 1) }) } func TestFormatMentionsForEmail(t *testing.T) { tests := []struct { name string input string expected string useRegex bool // If true, expected is treated as a regex pattern }{ { name: "empty string", input: "", expected: "", }, { name: "no mentions", input: "

Lorem Ipsum dolor sit amet

", expected: "

Lorem Ipsum dolor sit amet

", }, { name: "single mention with data-label (new format)", input: `

hello

`, expected: `

@Frederick hello

`, }, { name: "single mention with full name in data-label", input: `

please help

`, expected: `

@John Doe please help

`, }, { name: "mention without data-label (fallback to data-id)", input: `

test

`, expected: `

@johndoe test

`, }, { name: "old format with text node inside", input: `

@user1 Lorem Ipsum

`, expected: `

user1user1 Lorem Ipsum

`, useRegex: true, }, { name: "old format with text node (data-id takes precedence over text)", input: `

@differentuser text

`, expected: `

@actualuser text

`, }, { name: "multiple mentions in one paragraph", input: `

Hey and , please review

`, expected: `

Hey @John and @Jane Doe, please review

`, }, { name: "mention at beginning", input: `

Lorem Ipsum

`, expected: `

User OneUser One Lorem Ipsum

`, useRegex: true, }, { name: "mention at end", input: `

Lorem Ipsum

`, expected: `

Lorem Ipsum User OneUser One

`, useRegex: true, }, { name: "mention in middle", input: `

Lorem Ipsum

`, expected: `

Lorem User OneUser One Ipsum

`, useRegex: true, }, { name: "same user mentioned multiple times", input: `

and again

`, expected: `

UserUser and UserUser again

`, useRegex: true, }, { name: "HTML preservation with links", input: `

Check this link and ask

`, expected: `

Check this link and ask @Expert

`, }, { name: "HTML preservation with multiple paragraphs", input: `

First paragraph with

Second paragraph

`, expected: `

First paragraph with UserUser

Second paragraph

`, useRegex: true, }, { name: "HTML preservation with bold and italic", input: `

Bold text and italic with

`, expected: `

Bold text and italic with UserUser

`, useRegex: true, }, { name: "special characters in data-label", input: `

test

`, expected: `

O'BrienO'Brien test

`, useRegex: true, }, { name: "special characters - ampersand in data-label", input: `

test

`, expected: `

Tom & JerryTom & Jerry test

`, useRegex: true, }, { name: "special characters - quotes in data-label", input: `

test

`, expected: `

"Nickname""Nickname" test

`, useRegex: true, }, { name: "mixed old and new format", input: `

and @old

`, expected: `

@New User and @old

`, }, { name: "self-closing tag format (XML-style)", input: `

hello

`, expected: `

@User

`, }, { name: "mention with only text content (no attributes) - old format edge case", input: `

@someuser test

`, expected: `

@someuser test

`, }, { name: "data-label takes precedence over data-id", input: `

test

`, expected: `

@John Smith test

`, }, { name: "unicode characters in data-label", input: `

test

`, expected: `

@Müller François test

`, }, { name: "emoji in data-label", input: `

test

`, expected: `

@Cool User 😎 test

`, }, { name: "nested HTML structure", input: `

Text with in div

`, expected: `

Text with @User in div

`, }, { name: "mention in list", input: ``, expected: ``, }, { name: "very long name", input: `

test

`, expected: `

@Christopher Montgomery Bartholomew Johnson-Smith III test

`, }, { name: "empty data-label and data-id with text content", input: `

@fallback test

`, expected: `

@fallback test

`, }, { name: "whitespace in data-label", input: `

test

`, expected: `

@ Spaces test

`, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { db.LoadAndAssertFixtures(t) s := db.NewSession() defer s.Close() result := formatMentionsForEmail(s, tt.input) if tt.useRegex { matched, err := regexp.MatchString(tt.expected, result) require.NoError(t, err, "Invalid regex pattern: %s", tt.expected) assert.True(t, matched, "Result does not match regex pattern.\nExpected pattern: %s\nActual result: %s", tt.expected, result) } else { assert.Equal(t, tt.expected, result) } }) } } func TestFormatMentionsForEmail_MalformedHTML(t *testing.T) { tests := []struct { name string input string }{ { name: "unclosed tag - returns original", input: `

Test `, }, { name: "invalid HTML entities", input: `

Test &invalid; entity

`, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { db.LoadAndAssertFixtures(t) s := db.NewSession() defer s.Close() result := formatMentionsForEmail(s, tt.input) // For malformed HTML, we expect it to either be fixed by the parser or returned as-is // The key is that it shouldn't panic or error assert.NotEmpty(t, result) }) } }