mirror of https://github.com/usememos/memos
chore: add store tests (#5397)
parent
12f32acd09
commit
bd02de9895
@ -0,0 +1,346 @@
|
|||||||
|
package test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Filename Field Tests
|
||||||
|
// Schema: filename (string, supports contains)
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
func TestAttachmentFilterFilenameContains(t *testing.T) {
|
||||||
|
tc := NewAttachmentFilterTestContext(t)
|
||||||
|
defer tc.Close()
|
||||||
|
|
||||||
|
tc.CreateAttachment(NewAttachmentBuilder(tc.CreatorID).Filename("report.pdf").MimeType("application/pdf"))
|
||||||
|
tc.CreateAttachment(NewAttachmentBuilder(tc.CreatorID).Filename("document.pdf").MimeType("application/pdf"))
|
||||||
|
tc.CreateAttachment(NewAttachmentBuilder(tc.CreatorID).Filename("image.png").MimeType("image/png"))
|
||||||
|
|
||||||
|
// Test: filename.contains("report") - single match
|
||||||
|
attachments := tc.ListWithFilter(`filename.contains("report")`)
|
||||||
|
require.Len(t, attachments, 1)
|
||||||
|
require.Contains(t, attachments[0].Filename, "report")
|
||||||
|
|
||||||
|
// Test: filename.contains(".pdf") - multiple matches
|
||||||
|
attachments = tc.ListWithFilter(`filename.contains(".pdf")`)
|
||||||
|
require.Len(t, attachments, 2)
|
||||||
|
|
||||||
|
// Test: filename.contains("nonexistent") - no matches
|
||||||
|
attachments = tc.ListWithFilter(`filename.contains("nonexistent")`)
|
||||||
|
require.Len(t, attachments, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAttachmentFilterFilenameSpecialCharacters(t *testing.T) {
|
||||||
|
tc := NewAttachmentFilterTestContext(t)
|
||||||
|
defer tc.Close()
|
||||||
|
|
||||||
|
tc.CreateAttachment(NewAttachmentBuilder(tc.CreatorID).
|
||||||
|
Filename("file_with-special.chars@2024.pdf").MimeType("application/pdf"))
|
||||||
|
|
||||||
|
// Test: filename.contains with underscore
|
||||||
|
attachments := tc.ListWithFilter(`filename.contains("_with")`)
|
||||||
|
require.Len(t, attachments, 1)
|
||||||
|
|
||||||
|
// Test: filename.contains with @
|
||||||
|
attachments = tc.ListWithFilter(`filename.contains("@2024")`)
|
||||||
|
require.Len(t, attachments, 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAttachmentFilterFilenameUnicode(t *testing.T) {
|
||||||
|
tc := NewAttachmentFilterTestContext(t)
|
||||||
|
defer tc.Close()
|
||||||
|
|
||||||
|
tc.CreateAttachment(NewAttachmentBuilder(tc.CreatorID).
|
||||||
|
Filename("document_报告.pdf").MimeType("application/pdf"))
|
||||||
|
|
||||||
|
attachments := tc.ListWithFilter(`filename.contains("报告")`)
|
||||||
|
require.Len(t, attachments, 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Mime Type Field Tests
|
||||||
|
// Schema: mime_type (string, ==, !=)
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
func TestAttachmentFilterMimeTypeEquals(t *testing.T) {
|
||||||
|
tc := NewAttachmentFilterTestContext(t)
|
||||||
|
defer tc.Close()
|
||||||
|
|
||||||
|
tc.CreateAttachment(NewAttachmentBuilder(tc.CreatorID).Filename("image.png").MimeType("image/png"))
|
||||||
|
tc.CreateAttachment(NewAttachmentBuilder(tc.CreatorID).Filename("photo.jpeg").MimeType("image/jpeg"))
|
||||||
|
tc.CreateAttachment(NewAttachmentBuilder(tc.CreatorID).Filename("document.pdf").MimeType("application/pdf"))
|
||||||
|
|
||||||
|
// Test: mime_type == "image/png"
|
||||||
|
attachments := tc.ListWithFilter(`mime_type == "image/png"`)
|
||||||
|
require.Len(t, attachments, 1)
|
||||||
|
require.Equal(t, "image/png", attachments[0].Type)
|
||||||
|
|
||||||
|
// Test: mime_type == "application/pdf"
|
||||||
|
attachments = tc.ListWithFilter(`mime_type == "application/pdf"`)
|
||||||
|
require.Len(t, attachments, 1)
|
||||||
|
require.Equal(t, "application/pdf", attachments[0].Type)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAttachmentFilterMimeTypeNotEquals(t *testing.T) {
|
||||||
|
tc := NewAttachmentFilterTestContext(t)
|
||||||
|
defer tc.Close()
|
||||||
|
|
||||||
|
tc.CreateAttachment(NewAttachmentBuilder(tc.CreatorID).Filename("image.png").MimeType("image/png"))
|
||||||
|
tc.CreateAttachment(NewAttachmentBuilder(tc.CreatorID).Filename("document.pdf").MimeType("application/pdf"))
|
||||||
|
|
||||||
|
attachments := tc.ListWithFilter(`mime_type != "image/png"`)
|
||||||
|
require.Len(t, attachments, 1)
|
||||||
|
require.Equal(t, "application/pdf", attachments[0].Type)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAttachmentFilterMimeTypeInList(t *testing.T) {
|
||||||
|
tc := NewAttachmentFilterTestContext(t)
|
||||||
|
defer tc.Close()
|
||||||
|
|
||||||
|
tc.CreateAttachment(NewAttachmentBuilder(tc.CreatorID).Filename("image.png").MimeType("image/png"))
|
||||||
|
tc.CreateAttachment(NewAttachmentBuilder(tc.CreatorID).Filename("photo.jpeg").MimeType("image/jpeg"))
|
||||||
|
tc.CreateAttachment(NewAttachmentBuilder(tc.CreatorID).Filename("document.pdf").MimeType("application/pdf"))
|
||||||
|
|
||||||
|
// Test: mime_type in ["image/png", "image/jpeg"] - matches images
|
||||||
|
attachments := tc.ListWithFilter(`mime_type in ["image/png", "image/jpeg"]`)
|
||||||
|
require.Len(t, attachments, 2)
|
||||||
|
|
||||||
|
// Test: mime_type in ["video/mp4"] - no matches
|
||||||
|
attachments = tc.ListWithFilter(`mime_type in ["video/mp4"]`)
|
||||||
|
require.Len(t, attachments, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Create Time Field Tests
|
||||||
|
// Schema: create_time (timestamp, all comparison operators)
|
||||||
|
// Functions: now(), arithmetic (+, -, *)
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
func TestAttachmentFilterCreateTimeComparison(t *testing.T) {
|
||||||
|
tc := NewAttachmentFilterTestContext(t)
|
||||||
|
defer tc.Close()
|
||||||
|
|
||||||
|
now := time.Now().Unix()
|
||||||
|
tc.CreateAttachment(NewAttachmentBuilder(tc.CreatorID).Filename("test.png").MimeType("image/png"))
|
||||||
|
|
||||||
|
// Test: create_time < future (should match)
|
||||||
|
attachments := tc.ListWithFilter(`create_time < ` + formatInt64(now+3600))
|
||||||
|
require.Len(t, attachments, 1)
|
||||||
|
|
||||||
|
// Test: create_time > past (should match)
|
||||||
|
attachments = tc.ListWithFilter(`create_time > ` + formatInt64(now-3600))
|
||||||
|
require.Len(t, attachments, 1)
|
||||||
|
|
||||||
|
// Test: create_time > future (should not match)
|
||||||
|
attachments = tc.ListWithFilter(`create_time > ` + formatInt64(now+3600))
|
||||||
|
require.Len(t, attachments, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAttachmentFilterCreateTimeWithNow(t *testing.T) {
|
||||||
|
tc := NewAttachmentFilterTestContext(t)
|
||||||
|
defer tc.Close()
|
||||||
|
|
||||||
|
tc.CreateAttachment(NewAttachmentBuilder(tc.CreatorID).Filename("test.png").MimeType("image/png"))
|
||||||
|
|
||||||
|
// Test: create_time < now() + 5 (buffer for container clock drift)
|
||||||
|
attachments := tc.ListWithFilter(`create_time < now() + 5`)
|
||||||
|
require.Len(t, attachments, 1)
|
||||||
|
|
||||||
|
// Test: create_time > now() + 5 (should not match)
|
||||||
|
attachments = tc.ListWithFilter(`create_time > now() + 5`)
|
||||||
|
require.Len(t, attachments, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAttachmentFilterCreateTimeArithmetic(t *testing.T) {
|
||||||
|
tc := NewAttachmentFilterTestContext(t)
|
||||||
|
defer tc.Close()
|
||||||
|
|
||||||
|
tc.CreateAttachment(NewAttachmentBuilder(tc.CreatorID).Filename("test.png").MimeType("image/png"))
|
||||||
|
|
||||||
|
// Test: create_time >= now() - 3600 (attachments created in last hour)
|
||||||
|
attachments := tc.ListWithFilter(`create_time >= now() - 3600`)
|
||||||
|
require.Len(t, attachments, 1)
|
||||||
|
|
||||||
|
// Test: create_time < now() - 86400 (attachments older than 1 day - should be empty)
|
||||||
|
attachments = tc.ListWithFilter(`create_time < now() - 86400`)
|
||||||
|
require.Len(t, attachments, 0)
|
||||||
|
|
||||||
|
// Test: Multiplication - create_time >= now() - 60 * 60
|
||||||
|
attachments = tc.ListWithFilter(`create_time >= now() - 60 * 60`)
|
||||||
|
require.Len(t, attachments, 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAttachmentFilterAllComparisonOperators(t *testing.T) {
|
||||||
|
tc := NewAttachmentFilterTestContext(t)
|
||||||
|
defer tc.Close()
|
||||||
|
|
||||||
|
tc.CreateAttachment(NewAttachmentBuilder(tc.CreatorID).Filename("test.png").MimeType("image/png"))
|
||||||
|
|
||||||
|
// Test: < (less than)
|
||||||
|
attachments := tc.ListWithFilter(`create_time < now() + 3600`)
|
||||||
|
require.Len(t, attachments, 1)
|
||||||
|
|
||||||
|
// Test: <= (less than or equal) with buffer for clock drift
|
||||||
|
attachments = tc.ListWithFilter(`create_time < now() + 5`)
|
||||||
|
require.Len(t, attachments, 1)
|
||||||
|
|
||||||
|
// Test: > (greater than)
|
||||||
|
attachments = tc.ListWithFilter(`create_time > now() - 3600`)
|
||||||
|
require.Len(t, attachments, 1)
|
||||||
|
|
||||||
|
// Test: >= (greater than or equal)
|
||||||
|
attachments = tc.ListWithFilter(`create_time >= now() - 60`)
|
||||||
|
require.Len(t, attachments, 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Memo ID Field Tests
|
||||||
|
// Schema: memo_id (int, ==, !=)
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
func TestAttachmentFilterMemoIdEquals(t *testing.T) {
|
||||||
|
tc := NewAttachmentFilterTestContextWithUser(t)
|
||||||
|
defer tc.Close()
|
||||||
|
|
||||||
|
memo1 := tc.CreateMemo("memo-1", "Memo 1")
|
||||||
|
memo2 := tc.CreateMemo("memo-2", "Memo 2")
|
||||||
|
|
||||||
|
tc.CreateAttachment(NewAttachmentBuilder(tc.CreatorID).Filename("memo1_attachment.png").MimeType("image/png").MemoID(&memo1.ID))
|
||||||
|
tc.CreateAttachment(NewAttachmentBuilder(tc.CreatorID).Filename("memo2_attachment.png").MimeType("image/png").MemoID(&memo2.ID))
|
||||||
|
|
||||||
|
attachments := tc.ListWithFilter(`memo_id == ` + formatInt32(memo1.ID))
|
||||||
|
require.Len(t, attachments, 1)
|
||||||
|
require.Equal(t, &memo1.ID, attachments[0].MemoID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAttachmentFilterMemoIdNotEquals(t *testing.T) {
|
||||||
|
tc := NewAttachmentFilterTestContextWithUser(t)
|
||||||
|
defer tc.Close()
|
||||||
|
|
||||||
|
memo1 := tc.CreateMemo("memo-1", "Memo 1")
|
||||||
|
memo2 := tc.CreateMemo("memo-2", "Memo 2")
|
||||||
|
|
||||||
|
tc.CreateAttachment(NewAttachmentBuilder(tc.CreatorID).Filename("memo1_attachment.png").MimeType("image/png").MemoID(&memo1.ID))
|
||||||
|
tc.CreateAttachment(NewAttachmentBuilder(tc.CreatorID).Filename("memo2_attachment.png").MimeType("image/png").MemoID(&memo2.ID))
|
||||||
|
|
||||||
|
attachments := tc.ListWithFilter(`memo_id != ` + formatInt32(memo1.ID))
|
||||||
|
require.Len(t, attachments, 1)
|
||||||
|
require.Equal(t, &memo2.ID, attachments[0].MemoID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Logical Operator Tests
|
||||||
|
// Operators: && (AND), || (OR), ! (NOT)
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
func TestAttachmentFilterLogicalAnd(t *testing.T) {
|
||||||
|
tc := NewAttachmentFilterTestContext(t)
|
||||||
|
defer tc.Close()
|
||||||
|
|
||||||
|
tc.CreateAttachment(NewAttachmentBuilder(tc.CreatorID).Filename("image.png").MimeType("image/png"))
|
||||||
|
tc.CreateAttachment(NewAttachmentBuilder(tc.CreatorID).Filename("photo.png").MimeType("image/png"))
|
||||||
|
tc.CreateAttachment(NewAttachmentBuilder(tc.CreatorID).Filename("image.pdf").MimeType("application/pdf"))
|
||||||
|
|
||||||
|
attachments := tc.ListWithFilter(`mime_type == "image/png" && filename.contains("image")`)
|
||||||
|
require.Len(t, attachments, 1)
|
||||||
|
require.Equal(t, "image.png", attachments[0].Filename)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAttachmentFilterLogicalOr(t *testing.T) {
|
||||||
|
tc := NewAttachmentFilterTestContext(t)
|
||||||
|
defer tc.Close()
|
||||||
|
|
||||||
|
tc.CreateAttachment(NewAttachmentBuilder(tc.CreatorID).Filename("image.png").MimeType("image/png"))
|
||||||
|
tc.CreateAttachment(NewAttachmentBuilder(tc.CreatorID).Filename("document.pdf").MimeType("application/pdf"))
|
||||||
|
tc.CreateAttachment(NewAttachmentBuilder(tc.CreatorID).Filename("video.mp4").MimeType("video/mp4"))
|
||||||
|
|
||||||
|
attachments := tc.ListWithFilter(`mime_type == "image/png" || mime_type == "application/pdf"`)
|
||||||
|
require.Len(t, attachments, 2)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAttachmentFilterLogicalNot(t *testing.T) {
|
||||||
|
tc := NewAttachmentFilterTestContext(t)
|
||||||
|
defer tc.Close()
|
||||||
|
|
||||||
|
tc.CreateAttachment(NewAttachmentBuilder(tc.CreatorID).Filename("image.png").MimeType("image/png"))
|
||||||
|
tc.CreateAttachment(NewAttachmentBuilder(tc.CreatorID).Filename("document.pdf").MimeType("application/pdf"))
|
||||||
|
|
||||||
|
attachments := tc.ListWithFilter(`!(mime_type == "image/png")`)
|
||||||
|
require.Len(t, attachments, 1)
|
||||||
|
require.Equal(t, "application/pdf", attachments[0].Type)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAttachmentFilterComplexLogical(t *testing.T) {
|
||||||
|
tc := NewAttachmentFilterTestContext(t)
|
||||||
|
defer tc.Close()
|
||||||
|
|
||||||
|
tc.CreateAttachment(NewAttachmentBuilder(tc.CreatorID).Filename("report.png").MimeType("image/png"))
|
||||||
|
tc.CreateAttachment(NewAttachmentBuilder(tc.CreatorID).Filename("report.pdf").MimeType("application/pdf"))
|
||||||
|
tc.CreateAttachment(NewAttachmentBuilder(tc.CreatorID).Filename("other.png").MimeType("image/png"))
|
||||||
|
|
||||||
|
attachments := tc.ListWithFilter(`(mime_type == "image/png" || mime_type == "application/pdf") && filename.contains("report")`)
|
||||||
|
require.Len(t, attachments, 2)
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Multiple Filters Tests
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
func TestAttachmentFilterMultipleFilters(t *testing.T) {
|
||||||
|
tc := NewAttachmentFilterTestContext(t)
|
||||||
|
defer tc.Close()
|
||||||
|
|
||||||
|
tc.CreateAttachment(NewAttachmentBuilder(tc.CreatorID).Filename("report.png").MimeType("image/png"))
|
||||||
|
tc.CreateAttachment(NewAttachmentBuilder(tc.CreatorID).Filename("other.png").MimeType("image/png"))
|
||||||
|
tc.CreateAttachment(NewAttachmentBuilder(tc.CreatorID).Filename("report.pdf").MimeType("application/pdf"))
|
||||||
|
|
||||||
|
// Test: Multiple filters (applied as AND)
|
||||||
|
attachments := tc.ListWithFilters(`filename.contains("report")`, `mime_type == "image/png"`)
|
||||||
|
require.Len(t, attachments, 1)
|
||||||
|
require.Contains(t, attachments[0].Filename, "report")
|
||||||
|
require.Equal(t, "image/png", attachments[0].Type)
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Edge Cases
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
func TestAttachmentFilterNoMatches(t *testing.T) {
|
||||||
|
tc := NewAttachmentFilterTestContext(t)
|
||||||
|
defer tc.Close()
|
||||||
|
|
||||||
|
tc.CreateAttachment(NewAttachmentBuilder(tc.CreatorID).Filename("test.png").MimeType("image/png"))
|
||||||
|
|
||||||
|
attachments := tc.ListWithFilter(`filename.contains("nonexistent12345")`)
|
||||||
|
require.Len(t, attachments, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAttachmentFilterNullMemoId(t *testing.T) {
|
||||||
|
tc := NewAttachmentFilterTestContextWithUser(t)
|
||||||
|
defer tc.Close()
|
||||||
|
|
||||||
|
memo := tc.CreateMemo("memo-1", "Memo 1")
|
||||||
|
|
||||||
|
tc.CreateAttachment(NewAttachmentBuilder(tc.CreatorID).Filename("with_memo.png").MimeType("image/png").MemoID(&memo.ID))
|
||||||
|
tc.CreateAttachment(NewAttachmentBuilder(tc.CreatorID).Filename("no_memo.png").MimeType("image/png"))
|
||||||
|
|
||||||
|
attachments := tc.ListWithFilter(`memo_id == ` + formatInt32(memo.ID))
|
||||||
|
require.Len(t, attachments, 1)
|
||||||
|
require.Equal(t, "with_memo.png", attachments[0].Filename)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAttachmentFilterEmptyFilename(t *testing.T) {
|
||||||
|
tc := NewAttachmentFilterTestContext(t)
|
||||||
|
defer tc.Close()
|
||||||
|
|
||||||
|
tc.CreateAttachment(NewAttachmentBuilder(tc.CreatorID).Filename("test.png").MimeType("image/png"))
|
||||||
|
tc.CreateAttachment(NewAttachmentBuilder(tc.CreatorID).Filename("other.pdf").MimeType("application/pdf"))
|
||||||
|
|
||||||
|
// Test: filename.contains("") - should match all
|
||||||
|
attachments := tc.ListWithFilter(`filename.contains("")`)
|
||||||
|
require.Len(t, attachments, 2)
|
||||||
|
}
|
||||||
@ -0,0 +1,192 @@
|
|||||||
|
package test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"sync/atomic"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/pkg/errors"
|
||||||
|
"github.com/testcontainers/testcontainers-go"
|
||||||
|
"github.com/testcontainers/testcontainers-go/modules/mysql"
|
||||||
|
"github.com/testcontainers/testcontainers-go/modules/postgres"
|
||||||
|
"github.com/testcontainers/testcontainers-go/wait"
|
||||||
|
|
||||||
|
// Database drivers for connection verification.
|
||||||
|
_ "github.com/go-sql-driver/mysql"
|
||||||
|
_ "github.com/lib/pq"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
testUser = "root"
|
||||||
|
testPassword = "test"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
mysqlContainer *mysql.MySQLContainer
|
||||||
|
postgresContainer *postgres.PostgresContainer
|
||||||
|
mysqlOnce sync.Once
|
||||||
|
postgresOnce sync.Once
|
||||||
|
mysqlBaseDSN string
|
||||||
|
postgresBaseDSN string
|
||||||
|
dbCounter atomic.Int64
|
||||||
|
)
|
||||||
|
|
||||||
|
// GetMySQLDSN starts a MySQL container (if not already running) and creates a fresh database for this test.
|
||||||
|
func GetMySQLDSN(t *testing.T) string {
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
mysqlOnce.Do(func() {
|
||||||
|
container, err := mysql.Run(ctx,
|
||||||
|
"mysql:8",
|
||||||
|
mysql.WithDatabase("init_db"),
|
||||||
|
mysql.WithUsername("root"),
|
||||||
|
mysql.WithPassword(testPassword),
|
||||||
|
testcontainers.WithEnv(map[string]string{
|
||||||
|
"MYSQL_ROOT_PASSWORD": testPassword,
|
||||||
|
}),
|
||||||
|
testcontainers.WithWaitStrategy(
|
||||||
|
wait.ForAll(
|
||||||
|
wait.ForLog("ready for connections").WithOccurrence(2),
|
||||||
|
wait.ForListeningPort("3306/tcp"),
|
||||||
|
).WithDeadline(120*time.Second),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to start MySQL container: %v", err)
|
||||||
|
}
|
||||||
|
mysqlContainer = container
|
||||||
|
|
||||||
|
dsn, err := container.ConnectionString(ctx, "multiStatements=true")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to get MySQL connection string: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := waitForDB("mysql", dsn, 30*time.Second); err != nil {
|
||||||
|
t.Fatalf("MySQL not ready for connections: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
mysqlBaseDSN = dsn
|
||||||
|
})
|
||||||
|
|
||||||
|
if mysqlBaseDSN == "" {
|
||||||
|
t.Fatal("MySQL container failed to start in a previous test")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create a fresh database for this test
|
||||||
|
dbName := fmt.Sprintf("memos_test_%d", dbCounter.Add(1))
|
||||||
|
db, err := sql.Open("mysql", mysqlBaseDSN)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to connect to MySQL: %v", err)
|
||||||
|
}
|
||||||
|
defer db.Close()
|
||||||
|
|
||||||
|
if _, err := db.ExecContext(ctx, fmt.Sprintf("CREATE DATABASE `%s`", dbName)); err != nil {
|
||||||
|
t.Fatalf("failed to create database %s: %v", dbName, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return DSN pointing to the new database
|
||||||
|
return strings.Replace(mysqlBaseDSN, "/init_db?", "/"+dbName+"?", 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
// waitForDB polls the database until it's ready or timeout is reached.
|
||||||
|
func waitForDB(driver, dsn string, timeout time.Duration) error {
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), timeout)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
ticker := time.NewTicker(500 * time.Millisecond)
|
||||||
|
defer ticker.Stop()
|
||||||
|
|
||||||
|
var lastErr error
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
if lastErr != nil {
|
||||||
|
return errors.Errorf("timeout waiting for %s database: %v", driver, lastErr)
|
||||||
|
}
|
||||||
|
return errors.Errorf("timeout waiting for %s database to be ready", driver)
|
||||||
|
case <-ticker.C:
|
||||||
|
db, err := sql.Open(driver, dsn)
|
||||||
|
if err != nil {
|
||||||
|
lastErr = err
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
err = db.PingContext(ctx)
|
||||||
|
db.Close()
|
||||||
|
if err == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
lastErr = err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetPostgresDSN starts a PostgreSQL container (if not already running) and creates a fresh database for this test.
|
||||||
|
func GetPostgresDSN(t *testing.T) string {
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
postgresOnce.Do(func() {
|
||||||
|
container, err := postgres.Run(ctx,
|
||||||
|
"postgres:18",
|
||||||
|
postgres.WithDatabase("init_db"),
|
||||||
|
postgres.WithUsername(testUser),
|
||||||
|
postgres.WithPassword(testPassword),
|
||||||
|
testcontainers.WithWaitStrategy(
|
||||||
|
wait.ForAll(
|
||||||
|
wait.ForLog("database system is ready to accept connections").WithOccurrence(2),
|
||||||
|
wait.ForListeningPort("5432/tcp"),
|
||||||
|
).WithDeadline(120*time.Second),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to start PostgreSQL container: %v", err)
|
||||||
|
}
|
||||||
|
postgresContainer = container
|
||||||
|
|
||||||
|
dsn, err := container.ConnectionString(ctx, "sslmode=disable")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to get PostgreSQL connection string: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := waitForDB("postgres", dsn, 30*time.Second); err != nil {
|
||||||
|
t.Fatalf("PostgreSQL not ready for connections: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
postgresBaseDSN = dsn
|
||||||
|
})
|
||||||
|
|
||||||
|
if postgresBaseDSN == "" {
|
||||||
|
t.Fatal("PostgreSQL container failed to start in a previous test")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create a fresh database for this test
|
||||||
|
dbName := fmt.Sprintf("memos_test_%d", dbCounter.Add(1))
|
||||||
|
db, err := sql.Open("postgres", postgresBaseDSN)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to connect to PostgreSQL: %v", err)
|
||||||
|
}
|
||||||
|
defer db.Close()
|
||||||
|
|
||||||
|
if _, err := db.ExecContext(ctx, fmt.Sprintf("CREATE DATABASE %s", dbName)); err != nil {
|
||||||
|
t.Fatalf("failed to create database %s: %v", dbName, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return DSN pointing to the new database
|
||||||
|
return strings.Replace(postgresBaseDSN, "/init_db?", "/"+dbName+"?", 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TerminateContainers cleans up all running containers.
|
||||||
|
// This is typically called from TestMain.
|
||||||
|
func TerminateContainers() {
|
||||||
|
ctx := context.Background()
|
||||||
|
if mysqlContainer != nil {
|
||||||
|
_ = mysqlContainer.Terminate(ctx)
|
||||||
|
}
|
||||||
|
if postgresContainer != nil {
|
||||||
|
_ = postgresContainer.Terminate(ctx)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,290 @@
|
|||||||
|
package test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"strconv"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/lithammer/shortuuid/v4"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
|
||||||
|
storepb "github.com/usememos/memos/proto/gen/store"
|
||||||
|
"github.com/usememos/memos/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Formatting Helpers
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
func formatInt64(n int64) string {
|
||||||
|
return strconv.FormatInt(n, 10)
|
||||||
|
}
|
||||||
|
|
||||||
|
func formatInt32(n int32) string {
|
||||||
|
return strconv.FormatInt(int64(n), 10)
|
||||||
|
}
|
||||||
|
|
||||||
|
func formatInt(n int) string {
|
||||||
|
return strconv.Itoa(n)
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Pointer Helpers
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
func boolPtr(b bool) *bool {
|
||||||
|
return &b
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Test Fixture Builders
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
// MemoBuilder provides a fluent API for creating test memos.
|
||||||
|
type MemoBuilder struct {
|
||||||
|
memo *store.Memo
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewMemoBuilder creates a new memo builder with required fields.
|
||||||
|
func NewMemoBuilder(uid string, creatorID int32) *MemoBuilder {
|
||||||
|
return &MemoBuilder{
|
||||||
|
memo: &store.Memo{
|
||||||
|
UID: uid,
|
||||||
|
CreatorID: creatorID,
|
||||||
|
Visibility: store.Public,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *MemoBuilder) Content(content string) *MemoBuilder {
|
||||||
|
b.memo.Content = content
|
||||||
|
return b
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *MemoBuilder) Visibility(v store.Visibility) *MemoBuilder {
|
||||||
|
b.memo.Visibility = v
|
||||||
|
return b
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *MemoBuilder) Tags(tags ...string) *MemoBuilder {
|
||||||
|
if b.memo.Payload == nil {
|
||||||
|
b.memo.Payload = &storepb.MemoPayload{}
|
||||||
|
}
|
||||||
|
b.memo.Payload.Tags = tags
|
||||||
|
return b
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *MemoBuilder) Property(fn func(*storepb.MemoPayload_Property)) *MemoBuilder {
|
||||||
|
if b.memo.Payload == nil {
|
||||||
|
b.memo.Payload = &storepb.MemoPayload{}
|
||||||
|
}
|
||||||
|
if b.memo.Payload.Property == nil {
|
||||||
|
b.memo.Payload.Property = &storepb.MemoPayload_Property{}
|
||||||
|
}
|
||||||
|
fn(b.memo.Payload.Property)
|
||||||
|
return b
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *MemoBuilder) Build() *store.Memo {
|
||||||
|
return b.memo
|
||||||
|
}
|
||||||
|
|
||||||
|
// AttachmentBuilder provides a fluent API for creating test attachments.
|
||||||
|
type AttachmentBuilder struct {
|
||||||
|
attachment *store.Attachment
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewAttachmentBuilder creates a new attachment builder with required fields.
|
||||||
|
func NewAttachmentBuilder(creatorID int32) *AttachmentBuilder {
|
||||||
|
return &AttachmentBuilder{
|
||||||
|
attachment: &store.Attachment{
|
||||||
|
UID: shortuuid.New(),
|
||||||
|
CreatorID: creatorID,
|
||||||
|
Blob: []byte("test"),
|
||||||
|
Size: 1000,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *AttachmentBuilder) Filename(filename string) *AttachmentBuilder {
|
||||||
|
b.attachment.Filename = filename
|
||||||
|
return b
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *AttachmentBuilder) MimeType(mimeType string) *AttachmentBuilder {
|
||||||
|
b.attachment.Type = mimeType
|
||||||
|
return b
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *AttachmentBuilder) MemoID(memoID *int32) *AttachmentBuilder {
|
||||||
|
b.attachment.MemoID = memoID
|
||||||
|
return b
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *AttachmentBuilder) Size(size int64) *AttachmentBuilder {
|
||||||
|
b.attachment.Size = size
|
||||||
|
return b
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *AttachmentBuilder) Build() *store.Attachment {
|
||||||
|
return b.attachment
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Test Context Helpers
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
// MemoFilterTestContext holds common test dependencies for memo filter tests.
|
||||||
|
type MemoFilterTestContext struct {
|
||||||
|
Ctx context.Context
|
||||||
|
T *testing.T
|
||||||
|
Store *store.Store
|
||||||
|
User *store.User
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewMemoFilterTestContext creates a new test context with store and user.
|
||||||
|
func NewMemoFilterTestContext(t *testing.T) *MemoFilterTestContext {
|
||||||
|
ctx := context.Background()
|
||||||
|
ts := NewTestingStore(ctx, t)
|
||||||
|
user, err := createTestingHostUser(ctx, ts)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
return &MemoFilterTestContext{
|
||||||
|
Ctx: ctx,
|
||||||
|
T: t,
|
||||||
|
Store: ts,
|
||||||
|
User: user,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateMemo creates a memo using the builder pattern.
|
||||||
|
func (tc *MemoFilterTestContext) CreateMemo(b *MemoBuilder) *store.Memo {
|
||||||
|
memo, err := tc.Store.CreateMemo(tc.Ctx, b.Build())
|
||||||
|
require.NoError(tc.T, err)
|
||||||
|
return memo
|
||||||
|
}
|
||||||
|
|
||||||
|
// PinMemo pins a memo by ID.
|
||||||
|
func (tc *MemoFilterTestContext) PinMemo(memoID int32) {
|
||||||
|
err := tc.Store.UpdateMemo(tc.Ctx, &store.UpdateMemo{
|
||||||
|
ID: memoID,
|
||||||
|
Pinned: boolPtr(true),
|
||||||
|
})
|
||||||
|
require.NoError(tc.T, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListWithFilter lists memos with the given filter and returns the count.
|
||||||
|
func (tc *MemoFilterTestContext) ListWithFilter(filter string) []*store.Memo {
|
||||||
|
memos, err := tc.Store.ListMemos(tc.Ctx, &store.FindMemo{
|
||||||
|
Filters: []string{filter},
|
||||||
|
})
|
||||||
|
require.NoError(tc.T, err)
|
||||||
|
return memos
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListWithFilters lists memos with multiple filters and returns the count.
|
||||||
|
func (tc *MemoFilterTestContext) ListWithFilters(filters ...string) []*store.Memo {
|
||||||
|
memos, err := tc.Store.ListMemos(tc.Ctx, &store.FindMemo{
|
||||||
|
Filters: filters,
|
||||||
|
})
|
||||||
|
require.NoError(tc.T, err)
|
||||||
|
return memos
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close closes the test store.
|
||||||
|
func (tc *MemoFilterTestContext) Close() {
|
||||||
|
tc.Store.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
// AttachmentFilterTestContext holds common test dependencies for attachment filter tests.
|
||||||
|
type AttachmentFilterTestContext struct {
|
||||||
|
Ctx context.Context
|
||||||
|
T *testing.T
|
||||||
|
Store *store.Store
|
||||||
|
User *store.User
|
||||||
|
CreatorID int32
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewAttachmentFilterTestContext creates a new test context for attachments.
|
||||||
|
func NewAttachmentFilterTestContext(t *testing.T) *AttachmentFilterTestContext {
|
||||||
|
ctx := context.Background()
|
||||||
|
ts := NewTestingStore(ctx, t)
|
||||||
|
|
||||||
|
return &AttachmentFilterTestContext{
|
||||||
|
Ctx: ctx,
|
||||||
|
T: t,
|
||||||
|
Store: ts,
|
||||||
|
CreatorID: 101,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewAttachmentFilterTestContextWithUser creates a new test context with a user.
|
||||||
|
func NewAttachmentFilterTestContextWithUser(t *testing.T) *AttachmentFilterTestContext {
|
||||||
|
ctx := context.Background()
|
||||||
|
ts := NewTestingStore(ctx, t)
|
||||||
|
user, err := createTestingHostUser(ctx, ts)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
return &AttachmentFilterTestContext{
|
||||||
|
Ctx: ctx,
|
||||||
|
T: t,
|
||||||
|
Store: ts,
|
||||||
|
User: user,
|
||||||
|
CreatorID: user.ID,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateAttachment creates an attachment using the builder pattern.
|
||||||
|
func (tc *AttachmentFilterTestContext) CreateAttachment(b *AttachmentBuilder) *store.Attachment {
|
||||||
|
attachment, err := tc.Store.CreateAttachment(tc.Ctx, b.Build())
|
||||||
|
require.NoError(tc.T, err)
|
||||||
|
return attachment
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateMemo creates a memo (for attachment tests that need memos).
|
||||||
|
func (tc *AttachmentFilterTestContext) CreateMemo(uid, content string) *store.Memo {
|
||||||
|
memo, err := tc.Store.CreateMemo(tc.Ctx, &store.Memo{
|
||||||
|
UID: uid,
|
||||||
|
CreatorID: tc.CreatorID,
|
||||||
|
Content: content,
|
||||||
|
Visibility: store.Public,
|
||||||
|
})
|
||||||
|
require.NoError(tc.T, err)
|
||||||
|
return memo
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListWithFilter lists attachments with the given filter.
|
||||||
|
func (tc *AttachmentFilterTestContext) ListWithFilter(filter string) []*store.Attachment {
|
||||||
|
attachments, err := tc.Store.ListAttachments(tc.Ctx, &store.FindAttachment{
|
||||||
|
CreatorID: &tc.CreatorID,
|
||||||
|
Filters: []string{filter},
|
||||||
|
})
|
||||||
|
require.NoError(tc.T, err)
|
||||||
|
return attachments
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListWithFilters lists attachments with multiple filters.
|
||||||
|
func (tc *AttachmentFilterTestContext) ListWithFilters(filters ...string) []*store.Attachment {
|
||||||
|
attachments, err := tc.Store.ListAttachments(tc.Ctx, &store.FindAttachment{
|
||||||
|
CreatorID: &tc.CreatorID,
|
||||||
|
Filters: filters,
|
||||||
|
})
|
||||||
|
require.NoError(tc.T, err)
|
||||||
|
return attachments
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close closes the test store.
|
||||||
|
func (tc *AttachmentFilterTestContext) Close() {
|
||||||
|
tc.Store.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Filter Test Case Definition
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
// FilterTestCase defines a single filter test case for table-driven tests.
|
||||||
|
type FilterTestCase struct {
|
||||||
|
Name string
|
||||||
|
Filter string
|
||||||
|
ExpectedCount int
|
||||||
|
}
|
||||||
@ -0,0 +1,50 @@
|
|||||||
|
package test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"runtime"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestMain(m *testing.M) {
|
||||||
|
// If DRIVER is set, run tests for that driver only
|
||||||
|
if os.Getenv("DRIVER") != "" {
|
||||||
|
defer TerminateContainers()
|
||||||
|
m.Run()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// No DRIVER set - run tests for all drivers sequentially
|
||||||
|
runAllDrivers()
|
||||||
|
}
|
||||||
|
|
||||||
|
func runAllDrivers() {
|
||||||
|
drivers := []string{"sqlite", "mysql", "postgres"}
|
||||||
|
_, currentFile, _, _ := runtime.Caller(0)
|
||||||
|
projectRoot := filepath.Dir(filepath.Dir(filepath.Dir(currentFile)))
|
||||||
|
|
||||||
|
var failed []string
|
||||||
|
for _, driver := range drivers {
|
||||||
|
fmt.Printf("\n==================== %s ====================\n\n", driver)
|
||||||
|
|
||||||
|
cmd := exec.Command("go", "test", "-v", "-count=1", "./store/test/...")
|
||||||
|
cmd.Dir = projectRoot
|
||||||
|
cmd.Env = append(os.Environ(), "DRIVER="+driver)
|
||||||
|
cmd.Stdout = os.Stdout
|
||||||
|
cmd.Stderr = os.Stderr
|
||||||
|
|
||||||
|
if err := cmd.Run(); err != nil {
|
||||||
|
failed = append(failed, driver)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Println()
|
||||||
|
if len(failed) > 0 {
|
||||||
|
fmt.Printf("FAIL: %v\n", failed)
|
||||||
|
panic("some drivers failed")
|
||||||
|
}
|
||||||
|
fmt.Println("PASS: all drivers")
|
||||||
|
}
|
||||||
@ -0,0 +1,591 @@
|
|||||||
|
package test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
|
||||||
|
storepb "github.com/usememos/memos/proto/gen/store"
|
||||||
|
"github.com/usememos/memos/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Content Field Tests
|
||||||
|
// Schema: content (string, supports contains)
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
func TestMemoFilterContentContains(t *testing.T) {
|
||||||
|
tc := NewMemoFilterTestContext(t)
|
||||||
|
defer tc.Close()
|
||||||
|
|
||||||
|
// Create memos with different content
|
||||||
|
tc.CreateMemo(NewMemoBuilder("memo-hello", tc.User.ID).Content("Hello world"))
|
||||||
|
tc.CreateMemo(NewMemoBuilder("memo-goodbye", tc.User.ID).Content("Goodbye world"))
|
||||||
|
tc.CreateMemo(NewMemoBuilder("memo-test", tc.User.ID).Content("Testing content"))
|
||||||
|
|
||||||
|
// Test: content.contains("Hello") - single match
|
||||||
|
memos := tc.ListWithFilter(`content.contains("Hello")`)
|
||||||
|
require.Len(t, memos, 1)
|
||||||
|
require.Contains(t, memos[0].Content, "Hello")
|
||||||
|
|
||||||
|
// Test: content.contains("world") - multiple matches
|
||||||
|
memos = tc.ListWithFilter(`content.contains("world")`)
|
||||||
|
require.Len(t, memos, 2)
|
||||||
|
|
||||||
|
// Test: content.contains("nonexistent") - no matches
|
||||||
|
memos = tc.ListWithFilter(`content.contains("nonexistent")`)
|
||||||
|
require.Len(t, memos, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMemoFilterContentSpecialCharacters(t *testing.T) {
|
||||||
|
tc := NewMemoFilterTestContext(t)
|
||||||
|
defer tc.Close()
|
||||||
|
|
||||||
|
tc.CreateMemo(NewMemoBuilder("memo-special", tc.User.ID).Content("Special chars: @#$%^&*()"))
|
||||||
|
|
||||||
|
memos := tc.ListWithFilter(`content.contains("@#$%")`)
|
||||||
|
require.Len(t, memos, 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMemoFilterContentUnicode(t *testing.T) {
|
||||||
|
tc := NewMemoFilterTestContext(t)
|
||||||
|
defer tc.Close()
|
||||||
|
|
||||||
|
tc.CreateMemo(NewMemoBuilder("memo-unicode", tc.User.ID).Content("Unicode test: 你好世界 🌍"))
|
||||||
|
|
||||||
|
memos := tc.ListWithFilter(`content.contains("你好")`)
|
||||||
|
require.Len(t, memos, 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Visibility Field Tests
|
||||||
|
// Schema: visibility (string, ==, !=)
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
func TestMemoFilterVisibilityEquals(t *testing.T) {
|
||||||
|
tc := NewMemoFilterTestContext(t)
|
||||||
|
defer tc.Close()
|
||||||
|
|
||||||
|
tc.CreateMemo(NewMemoBuilder("memo-public", tc.User.ID).Content("Public memo").Visibility(store.Public))
|
||||||
|
tc.CreateMemo(NewMemoBuilder("memo-private", tc.User.ID).Content("Private memo").Visibility(store.Private))
|
||||||
|
tc.CreateMemo(NewMemoBuilder("memo-protected", tc.User.ID).Content("Protected memo").Visibility(store.Protected))
|
||||||
|
|
||||||
|
// Test: visibility == "PUBLIC"
|
||||||
|
memos := tc.ListWithFilter(`visibility == "PUBLIC"`)
|
||||||
|
require.Len(t, memos, 1)
|
||||||
|
require.Equal(t, store.Public, memos[0].Visibility)
|
||||||
|
|
||||||
|
// Test: visibility == "PRIVATE"
|
||||||
|
memos = tc.ListWithFilter(`visibility == "PRIVATE"`)
|
||||||
|
require.Len(t, memos, 1)
|
||||||
|
require.Equal(t, store.Private, memos[0].Visibility)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMemoFilterVisibilityNotEquals(t *testing.T) {
|
||||||
|
tc := NewMemoFilterTestContext(t)
|
||||||
|
defer tc.Close()
|
||||||
|
|
||||||
|
tc.CreateMemo(NewMemoBuilder("memo-public", tc.User.ID).Content("Public memo").Visibility(store.Public))
|
||||||
|
tc.CreateMemo(NewMemoBuilder("memo-private", tc.User.ID).Content("Private memo").Visibility(store.Private))
|
||||||
|
|
||||||
|
memos := tc.ListWithFilter(`visibility != "PUBLIC"`)
|
||||||
|
require.Len(t, memos, 1)
|
||||||
|
require.Equal(t, store.Private, memos[0].Visibility)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMemoFilterVisibilityInList(t *testing.T) {
|
||||||
|
tc := NewMemoFilterTestContext(t)
|
||||||
|
defer tc.Close()
|
||||||
|
|
||||||
|
tc.CreateMemo(NewMemoBuilder("memo-pub", tc.User.ID).Visibility(store.Public))
|
||||||
|
tc.CreateMemo(NewMemoBuilder("memo-priv", tc.User.ID).Visibility(store.Private))
|
||||||
|
tc.CreateMemo(NewMemoBuilder("memo-prot", tc.User.ID).Visibility(store.Protected))
|
||||||
|
|
||||||
|
memos := tc.ListWithFilter(`visibility in ["PUBLIC", "PRIVATE"]`)
|
||||||
|
require.Len(t, memos, 2)
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Pinned Field Tests
|
||||||
|
// Schema: pinned (bool column, ==, !=, predicate)
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
func TestMemoFilterPinnedEquals(t *testing.T) {
|
||||||
|
tc := NewMemoFilterTestContext(t)
|
||||||
|
defer tc.Close()
|
||||||
|
|
||||||
|
pinnedMemo := tc.CreateMemo(NewMemoBuilder("memo-pinned", tc.User.ID).Content("Pinned memo"))
|
||||||
|
tc.PinMemo(pinnedMemo.ID)
|
||||||
|
tc.CreateMemo(NewMemoBuilder("memo-unpinned", tc.User.ID).Content("Unpinned memo"))
|
||||||
|
|
||||||
|
// Test: pinned == true
|
||||||
|
memos := tc.ListWithFilter(`pinned == true`)
|
||||||
|
require.Len(t, memos, 1)
|
||||||
|
require.True(t, memos[0].Pinned)
|
||||||
|
|
||||||
|
// Test: pinned == false
|
||||||
|
memos = tc.ListWithFilter(`pinned == false`)
|
||||||
|
require.Len(t, memos, 1)
|
||||||
|
require.False(t, memos[0].Pinned)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMemoFilterPinnedPredicate(t *testing.T) {
|
||||||
|
tc := NewMemoFilterTestContext(t)
|
||||||
|
defer tc.Close()
|
||||||
|
|
||||||
|
pinnedMemo := tc.CreateMemo(NewMemoBuilder("memo-pinned", tc.User.ID).Content("Pinned memo"))
|
||||||
|
tc.PinMemo(pinnedMemo.ID)
|
||||||
|
tc.CreateMemo(NewMemoBuilder("memo-unpinned", tc.User.ID).Content("Unpinned memo"))
|
||||||
|
|
||||||
|
memos := tc.ListWithFilter(`pinned`)
|
||||||
|
require.Len(t, memos, 1)
|
||||||
|
require.True(t, memos[0].Pinned)
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Creator ID Field Tests
|
||||||
|
// Schema: creator_id (int, ==, !=)
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
func TestMemoFilterCreatorIdEquals(t *testing.T) {
|
||||||
|
tc := NewMemoFilterTestContext(t)
|
||||||
|
defer tc.Close()
|
||||||
|
|
||||||
|
user2, err := tc.Store.CreateUser(tc.Ctx, &store.User{
|
||||||
|
Username: "user2",
|
||||||
|
Role: store.RoleUser,
|
||||||
|
Email: "user2@example.com",
|
||||||
|
Nickname: "User 2",
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
tc.CreateMemo(NewMemoBuilder("memo-user1", tc.User.ID).Content("User 1 memo"))
|
||||||
|
tc.CreateMemo(NewMemoBuilder("memo-user2", user2.ID).Content("User 2 memo"))
|
||||||
|
|
||||||
|
memos := tc.ListWithFilter(`creator_id == ` + formatInt(int(tc.User.ID)))
|
||||||
|
require.Len(t, memos, 1)
|
||||||
|
require.Equal(t, tc.User.ID, memos[0].CreatorID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMemoFilterCreatorIdNotEquals(t *testing.T) {
|
||||||
|
tc := NewMemoFilterTestContext(t)
|
||||||
|
defer tc.Close()
|
||||||
|
|
||||||
|
user2, err := tc.Store.CreateUser(tc.Ctx, &store.User{
|
||||||
|
Username: "user2",
|
||||||
|
Role: store.RoleUser,
|
||||||
|
Email: "user2@example.com",
|
||||||
|
Nickname: "User 2",
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
tc.CreateMemo(NewMemoBuilder("memo-user1", tc.User.ID).Content("User 1 memo"))
|
||||||
|
tc.CreateMemo(NewMemoBuilder("memo-user2", user2.ID).Content("User 2 memo"))
|
||||||
|
|
||||||
|
memos := tc.ListWithFilter(`creator_id != ` + formatInt(int(tc.User.ID)))
|
||||||
|
require.Len(t, memos, 1)
|
||||||
|
require.Equal(t, user2.ID, memos[0].CreatorID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Tags Field Tests
|
||||||
|
// Schema: tags (JSON list), tag (virtual alias)
|
||||||
|
// Operators: tag in [...], "value" in tags
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
func TestMemoFilterTagInList(t *testing.T) {
|
||||||
|
tc := NewMemoFilterTestContext(t)
|
||||||
|
defer tc.Close()
|
||||||
|
|
||||||
|
tc.CreateMemo(NewMemoBuilder("memo-work", tc.User.ID).Content("Work memo").Tags("work", "important"))
|
||||||
|
tc.CreateMemo(NewMemoBuilder("memo-personal", tc.User.ID).Content("Personal memo").Tags("personal", "fun"))
|
||||||
|
tc.CreateMemo(NewMemoBuilder("memo-no-tags", tc.User.ID).Content("No tags"))
|
||||||
|
|
||||||
|
// Test: tag in ["work"]
|
||||||
|
memos := tc.ListWithFilter(`tag in ["work"]`)
|
||||||
|
require.Len(t, memos, 1)
|
||||||
|
require.Contains(t, memos[0].Payload.Tags, "work")
|
||||||
|
|
||||||
|
// Test: tag in ["work", "personal"]
|
||||||
|
memos = tc.ListWithFilter(`tag in ["work", "personal"]`)
|
||||||
|
require.Len(t, memos, 2)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMemoFilterElementInTags(t *testing.T) {
|
||||||
|
tc := NewMemoFilterTestContext(t)
|
||||||
|
defer tc.Close()
|
||||||
|
|
||||||
|
tc.CreateMemo(NewMemoBuilder("memo-tagged", tc.User.ID).Content("Tagged memo").Tags("project", "todo"))
|
||||||
|
tc.CreateMemo(NewMemoBuilder("memo-untagged", tc.User.ID).Content("Untagged memo"))
|
||||||
|
|
||||||
|
// Test: "project" in tags
|
||||||
|
memos := tc.ListWithFilter(`"project" in tags`)
|
||||||
|
require.Len(t, memos, 1)
|
||||||
|
|
||||||
|
// Test: "nonexistent" in tags
|
||||||
|
memos = tc.ListWithFilter(`"nonexistent" in tags`)
|
||||||
|
require.Len(t, memos, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMemoFilterHierarchicalTags(t *testing.T) {
|
||||||
|
tc := NewMemoFilterTestContext(t)
|
||||||
|
defer tc.Close()
|
||||||
|
|
||||||
|
tc.CreateMemo(NewMemoBuilder("memo-book", tc.User.ID).Content("Book memo").Tags("book"))
|
||||||
|
tc.CreateMemo(NewMemoBuilder("memo-book-fiction", tc.User.ID).Content("Fiction book memo").Tags("book/fiction"))
|
||||||
|
|
||||||
|
// Test: tag in ["book"] should match both (hierarchical matching)
|
||||||
|
memos := tc.ListWithFilter(`tag in ["book"]`)
|
||||||
|
require.Len(t, memos, 2)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMemoFilterEmptyTags(t *testing.T) {
|
||||||
|
tc := NewMemoFilterTestContext(t)
|
||||||
|
defer tc.Close()
|
||||||
|
|
||||||
|
tc.CreateMemo(NewMemoBuilder("memo-empty-tags", tc.User.ID).Content("Empty tags").Tags())
|
||||||
|
|
||||||
|
memos := tc.ListWithFilter(`tag in ["anything"]`)
|
||||||
|
require.Len(t, memos, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// JSON Bool Field Tests
|
||||||
|
// Schema: has_task_list, has_link, has_code, has_incomplete_tasks
|
||||||
|
// Operators: ==, !=, predicate
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
func TestMemoFilterHasTaskList(t *testing.T) {
|
||||||
|
tc := NewMemoFilterTestContext(t)
|
||||||
|
defer tc.Close()
|
||||||
|
|
||||||
|
tc.CreateMemo(NewMemoBuilder("memo-with-tasks", tc.User.ID).
|
||||||
|
Content("- [ ] Task 1\n- [x] Task 2").
|
||||||
|
Property(func(p *storepb.MemoPayload_Property) { p.HasTaskList = true }))
|
||||||
|
tc.CreateMemo(NewMemoBuilder("memo-no-tasks", tc.User.ID).Content("No tasks here"))
|
||||||
|
|
||||||
|
// Test: has_task_list (predicate)
|
||||||
|
memos := tc.ListWithFilter(`has_task_list`)
|
||||||
|
require.Len(t, memos, 1)
|
||||||
|
require.True(t, memos[0].Payload.Property.HasTaskList)
|
||||||
|
|
||||||
|
// Test: has_task_list == true
|
||||||
|
memos = tc.ListWithFilter(`has_task_list == true`)
|
||||||
|
require.Len(t, memos, 1)
|
||||||
|
|
||||||
|
// Note: has_task_list == false is not tested because JSON boolean fields
|
||||||
|
// with false value may not be queryable when the field is not present in JSON
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMemoFilterHasLink(t *testing.T) {
|
||||||
|
tc := NewMemoFilterTestContext(t)
|
||||||
|
defer tc.Close()
|
||||||
|
|
||||||
|
tc.CreateMemo(NewMemoBuilder("memo-with-link", tc.User.ID).
|
||||||
|
Content("Check out https://example.com").
|
||||||
|
Property(func(p *storepb.MemoPayload_Property) { p.HasLink = true }))
|
||||||
|
tc.CreateMemo(NewMemoBuilder("memo-no-link", tc.User.ID).Content("No links"))
|
||||||
|
|
||||||
|
memos := tc.ListWithFilter(`has_link`)
|
||||||
|
require.Len(t, memos, 1)
|
||||||
|
require.True(t, memos[0].Payload.Property.HasLink)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMemoFilterHasCode(t *testing.T) {
|
||||||
|
tc := NewMemoFilterTestContext(t)
|
||||||
|
defer tc.Close()
|
||||||
|
|
||||||
|
tc.CreateMemo(NewMemoBuilder("memo-with-code", tc.User.ID).
|
||||||
|
Content("```go\nfmt.Println(\"Hello\")\n```").
|
||||||
|
Property(func(p *storepb.MemoPayload_Property) { p.HasCode = true }))
|
||||||
|
tc.CreateMemo(NewMemoBuilder("memo-no-code", tc.User.ID).Content("No code"))
|
||||||
|
|
||||||
|
memos := tc.ListWithFilter(`has_code`)
|
||||||
|
require.Len(t, memos, 1)
|
||||||
|
require.True(t, memos[0].Payload.Property.HasCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMemoFilterHasIncompleteTasks(t *testing.T) {
|
||||||
|
tc := NewMemoFilterTestContext(t)
|
||||||
|
defer tc.Close()
|
||||||
|
|
||||||
|
tc.CreateMemo(NewMemoBuilder("memo-incomplete", tc.User.ID).
|
||||||
|
Content("- [ ] Incomplete task").
|
||||||
|
Property(func(p *storepb.MemoPayload_Property) {
|
||||||
|
p.HasTaskList = true
|
||||||
|
p.HasIncompleteTasks = true
|
||||||
|
}))
|
||||||
|
tc.CreateMemo(NewMemoBuilder("memo-complete", tc.User.ID).
|
||||||
|
Content("- [x] Complete task").
|
||||||
|
Property(func(p *storepb.MemoPayload_Property) {
|
||||||
|
p.HasTaskList = true
|
||||||
|
p.HasIncompleteTasks = false
|
||||||
|
}))
|
||||||
|
|
||||||
|
memos := tc.ListWithFilter(`has_incomplete_tasks`)
|
||||||
|
require.Len(t, memos, 1)
|
||||||
|
require.True(t, memos[0].Payload.Property.HasIncompleteTasks)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMemoFilterCombinedJSONBool(t *testing.T) {
|
||||||
|
tc := NewMemoFilterTestContext(t)
|
||||||
|
defer tc.Close()
|
||||||
|
|
||||||
|
// Memo with all properties
|
||||||
|
tc.CreateMemo(NewMemoBuilder("memo-all-props", tc.User.ID).
|
||||||
|
Content("All properties").
|
||||||
|
Property(func(p *storepb.MemoPayload_Property) {
|
||||||
|
p.HasLink = true
|
||||||
|
p.HasTaskList = true
|
||||||
|
p.HasCode = true
|
||||||
|
p.HasIncompleteTasks = true
|
||||||
|
}))
|
||||||
|
|
||||||
|
// Memo with only link
|
||||||
|
tc.CreateMemo(NewMemoBuilder("memo-only-link", tc.User.ID).
|
||||||
|
Content("Only link").
|
||||||
|
Property(func(p *storepb.MemoPayload_Property) { p.HasLink = true }))
|
||||||
|
|
||||||
|
// Test: has_link && has_code
|
||||||
|
memos := tc.ListWithFilter(`has_link && has_code`)
|
||||||
|
require.Len(t, memos, 1)
|
||||||
|
|
||||||
|
// Test: has_task_list && has_incomplete_tasks
|
||||||
|
memos = tc.ListWithFilter(`has_task_list && has_incomplete_tasks`)
|
||||||
|
require.Len(t, memos, 1)
|
||||||
|
|
||||||
|
// Test: has_link || has_code
|
||||||
|
memos = tc.ListWithFilter(`has_link || has_code`)
|
||||||
|
require.Len(t, memos, 2)
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Timestamp Field Tests
|
||||||
|
// Schema: created_ts, updated_ts (timestamp, all comparison operators)
|
||||||
|
// Functions: now(), arithmetic (+, -, *)
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
func TestMemoFilterCreatedTsComparison(t *testing.T) {
|
||||||
|
tc := NewMemoFilterTestContext(t)
|
||||||
|
defer tc.Close()
|
||||||
|
|
||||||
|
now := time.Now().Unix()
|
||||||
|
tc.CreateMemo(NewMemoBuilder("memo-ts", tc.User.ID).Content("Timestamp test"))
|
||||||
|
|
||||||
|
// Test: created_ts < future (should match)
|
||||||
|
memos := tc.ListWithFilter(`created_ts < ` + formatInt64(now+3600))
|
||||||
|
require.Len(t, memos, 1)
|
||||||
|
|
||||||
|
// Test: created_ts > past (should match)
|
||||||
|
memos = tc.ListWithFilter(`created_ts > ` + formatInt64(now-3600))
|
||||||
|
require.Len(t, memos, 1)
|
||||||
|
|
||||||
|
// Test: created_ts > future (should not match)
|
||||||
|
memos = tc.ListWithFilter(`created_ts > ` + formatInt64(now+3600))
|
||||||
|
require.Len(t, memos, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMemoFilterCreatedTsWithNow(t *testing.T) {
|
||||||
|
tc := NewMemoFilterTestContext(t)
|
||||||
|
defer tc.Close()
|
||||||
|
|
||||||
|
tc.CreateMemo(NewMemoBuilder("memo-ts-test", tc.User.ID).Content("Timestamp test"))
|
||||||
|
|
||||||
|
// Test: created_ts < now() + 5 (buffer for container clock drift)
|
||||||
|
memos := tc.ListWithFilter(`created_ts < now() + 5`)
|
||||||
|
require.Len(t, memos, 1)
|
||||||
|
|
||||||
|
// Test: created_ts > now() + 5 (should not match)
|
||||||
|
memos = tc.ListWithFilter(`created_ts > now() + 5`)
|
||||||
|
require.Len(t, memos, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMemoFilterCreatedTsArithmetic(t *testing.T) {
|
||||||
|
tc := NewMemoFilterTestContext(t)
|
||||||
|
defer tc.Close()
|
||||||
|
|
||||||
|
tc.CreateMemo(NewMemoBuilder("memo-ts-arith", tc.User.ID).Content("Timestamp arithmetic test"))
|
||||||
|
|
||||||
|
// Test: created_ts >= now() - 3600 (memos created in last hour)
|
||||||
|
memos := tc.ListWithFilter(`created_ts >= now() - 3600`)
|
||||||
|
require.Len(t, memos, 1)
|
||||||
|
|
||||||
|
// Test: created_ts < now() - 86400 (memos older than 1 day - should be empty)
|
||||||
|
memos = tc.ListWithFilter(`created_ts < now() - 86400`)
|
||||||
|
require.Len(t, memos, 0)
|
||||||
|
|
||||||
|
// Test: Multiplication - created_ts >= now() - 60 * 60
|
||||||
|
memos = tc.ListWithFilter(`created_ts >= now() - 60 * 60`)
|
||||||
|
require.Len(t, memos, 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMemoFilterUpdatedTs(t *testing.T) {
|
||||||
|
tc := NewMemoFilterTestContext(t)
|
||||||
|
defer tc.Close()
|
||||||
|
|
||||||
|
memo := tc.CreateMemo(NewMemoBuilder("memo-updated", tc.User.ID).Content("Will be updated"))
|
||||||
|
|
||||||
|
// Update the memo
|
||||||
|
newContent := "Updated content"
|
||||||
|
err := tc.Store.UpdateMemo(tc.Ctx, &store.UpdateMemo{
|
||||||
|
ID: memo.ID,
|
||||||
|
Content: &newContent,
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// Test: updated_ts >= now() - 60 (updated in last minute)
|
||||||
|
memos := tc.ListWithFilter(`updated_ts >= now() - 60`)
|
||||||
|
require.Len(t, memos, 1)
|
||||||
|
|
||||||
|
// Test: updated_ts > now() + 3600 (should be empty)
|
||||||
|
memos = tc.ListWithFilter(`updated_ts > now() + 3600`)
|
||||||
|
require.Len(t, memos, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMemoFilterAllComparisonOperators(t *testing.T) {
|
||||||
|
tc := NewMemoFilterTestContext(t)
|
||||||
|
defer tc.Close()
|
||||||
|
|
||||||
|
tc.CreateMemo(NewMemoBuilder("memo-ops", tc.User.ID).Content("Comparison operators test"))
|
||||||
|
|
||||||
|
// Test: < (less than)
|
||||||
|
memos := tc.ListWithFilter(`created_ts < now() + 3600`)
|
||||||
|
require.Len(t, memos, 1)
|
||||||
|
|
||||||
|
// Test: <= (less than or equal) with buffer for clock drift
|
||||||
|
memos = tc.ListWithFilter(`created_ts < now() + 5`)
|
||||||
|
require.Len(t, memos, 1)
|
||||||
|
|
||||||
|
// Test: > (greater than)
|
||||||
|
memos = tc.ListWithFilter(`created_ts > now() - 3600`)
|
||||||
|
require.Len(t, memos, 1)
|
||||||
|
|
||||||
|
// Test: >= (greater than or equal)
|
||||||
|
memos = tc.ListWithFilter(`created_ts >= now() - 60`)
|
||||||
|
require.Len(t, memos, 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Logical Operator Tests
|
||||||
|
// Operators: && (AND), || (OR), ! (NOT)
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
func TestMemoFilterLogicalAnd(t *testing.T) {
|
||||||
|
tc := NewMemoFilterTestContext(t)
|
||||||
|
defer tc.Close()
|
||||||
|
|
||||||
|
pinnedMemo := tc.CreateMemo(NewMemoBuilder("memo-pinned-public", tc.User.ID).Content("Pinned public"))
|
||||||
|
tc.PinMemo(pinnedMemo.ID)
|
||||||
|
tc.CreateMemo(NewMemoBuilder("memo-unpinned-public", tc.User.ID).Content("Unpinned public"))
|
||||||
|
|
||||||
|
memos := tc.ListWithFilter(`pinned && visibility == "PUBLIC"`)
|
||||||
|
require.Len(t, memos, 1)
|
||||||
|
require.True(t, memos[0].Pinned)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMemoFilterLogicalOr(t *testing.T) {
|
||||||
|
tc := NewMemoFilterTestContext(t)
|
||||||
|
defer tc.Close()
|
||||||
|
|
||||||
|
tc.CreateMemo(NewMemoBuilder("memo-public", tc.User.ID).Visibility(store.Public))
|
||||||
|
tc.CreateMemo(NewMemoBuilder("memo-private", tc.User.ID).Visibility(store.Private))
|
||||||
|
tc.CreateMemo(NewMemoBuilder("memo-protected", tc.User.ID).Visibility(store.Protected))
|
||||||
|
|
||||||
|
memos := tc.ListWithFilter(`visibility == "PUBLIC" || visibility == "PRIVATE"`)
|
||||||
|
require.Len(t, memos, 2)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMemoFilterLogicalNot(t *testing.T) {
|
||||||
|
tc := NewMemoFilterTestContext(t)
|
||||||
|
defer tc.Close()
|
||||||
|
|
||||||
|
pinnedMemo := tc.CreateMemo(NewMemoBuilder("memo-pinned", tc.User.ID).Content("Pinned"))
|
||||||
|
tc.PinMemo(pinnedMemo.ID)
|
||||||
|
tc.CreateMemo(NewMemoBuilder("memo-unpinned", tc.User.ID).Content("Unpinned"))
|
||||||
|
|
||||||
|
memos := tc.ListWithFilter(`!pinned`)
|
||||||
|
require.Len(t, memos, 1)
|
||||||
|
require.False(t, memos[0].Pinned)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMemoFilterNegatedComparison(t *testing.T) {
|
||||||
|
tc := NewMemoFilterTestContext(t)
|
||||||
|
defer tc.Close()
|
||||||
|
|
||||||
|
tc.CreateMemo(NewMemoBuilder("memo-public", tc.User.ID).Visibility(store.Public))
|
||||||
|
tc.CreateMemo(NewMemoBuilder("memo-private", tc.User.ID).Visibility(store.Private))
|
||||||
|
|
||||||
|
memos := tc.ListWithFilter(`!(visibility == "PUBLIC")`)
|
||||||
|
require.Len(t, memos, 1)
|
||||||
|
require.Equal(t, store.Private, memos[0].Visibility)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMemoFilterComplexLogical(t *testing.T) {
|
||||||
|
tc := NewMemoFilterTestContext(t)
|
||||||
|
defer tc.Close()
|
||||||
|
|
||||||
|
// Create pinned public memo with tags
|
||||||
|
pinnedMemo := tc.CreateMemo(NewMemoBuilder("memo-pinned-tagged", tc.User.ID).
|
||||||
|
Content("Pinned and tagged").Tags("important"))
|
||||||
|
tc.PinMemo(pinnedMemo.ID)
|
||||||
|
|
||||||
|
// Create unpinned memo with same tag
|
||||||
|
tc.CreateMemo(NewMemoBuilder("memo-unpinned-tagged", tc.User.ID).
|
||||||
|
Content("Unpinned but tagged").Tags("important"))
|
||||||
|
|
||||||
|
// Create pinned memo without tag
|
||||||
|
pinned2 := tc.CreateMemo(NewMemoBuilder("memo-pinned-untagged", tc.User.ID).Content("Pinned but untagged"))
|
||||||
|
tc.PinMemo(pinned2.ID)
|
||||||
|
|
||||||
|
// Test: pinned && tag in ["important"]
|
||||||
|
memos := tc.ListWithFilter(`pinned && tag in ["important"]`)
|
||||||
|
require.Len(t, memos, 1)
|
||||||
|
|
||||||
|
// Test: (pinned || tag in ["important"]) && visibility == "PUBLIC"
|
||||||
|
memos = tc.ListWithFilter(`(pinned || tag in ["important"]) && visibility == "PUBLIC"`)
|
||||||
|
require.Len(t, memos, 3)
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Multiple Filters Tests
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
func TestMemoFilterMultipleFilters(t *testing.T) {
|
||||||
|
tc := NewMemoFilterTestContext(t)
|
||||||
|
defer tc.Close()
|
||||||
|
|
||||||
|
tc.CreateMemo(NewMemoBuilder("memo-public-hello", tc.User.ID).Content("Hello world").Visibility(store.Public))
|
||||||
|
tc.CreateMemo(NewMemoBuilder("memo-private-hello", tc.User.ID).Content("Hello private").Visibility(store.Private))
|
||||||
|
|
||||||
|
// Test: Multiple filters (applied as AND)
|
||||||
|
memos := tc.ListWithFilters(`content.contains("Hello")`, `visibility == "PUBLIC"`)
|
||||||
|
require.Len(t, memos, 1)
|
||||||
|
require.Contains(t, memos[0].Content, "Hello")
|
||||||
|
require.Equal(t, store.Public, memos[0].Visibility)
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Edge Cases
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
func TestMemoFilterNullPayload(t *testing.T) {
|
||||||
|
tc := NewMemoFilterTestContext(t)
|
||||||
|
defer tc.Close()
|
||||||
|
|
||||||
|
tc.CreateMemo(NewMemoBuilder("memo-null-payload", tc.User.ID).Content("Null payload"))
|
||||||
|
|
||||||
|
// Test: has_link should not crash and return no results
|
||||||
|
memos := tc.ListWithFilter(`has_link`)
|
||||||
|
require.Len(t, memos, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMemoFilterNoMatches(t *testing.T) {
|
||||||
|
tc := NewMemoFilterTestContext(t)
|
||||||
|
defer tc.Close()
|
||||||
|
|
||||||
|
tc.CreateMemo(NewMemoBuilder("memo-test", tc.User.ID).Content("Test content"))
|
||||||
|
|
||||||
|
memos := tc.ListWithFilter(`content.contains("nonexistent12345")`)
|
||||||
|
require.Len(t, memos, 0)
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue