chore: add store tests (#5397)

This commit is contained in:
Johnny 2025-12-31 21:26:35 +08:00 committed by GitHub
parent 12f32acd09
commit bd02de9895
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
21 changed files with 3177 additions and 95 deletions

56
go.mod
View File

@ -21,11 +21,14 @@ require (
github.com/pkg/errors v0.9.1
github.com/spf13/cobra v1.10.1
github.com/spf13/viper v1.20.1
github.com/stretchr/testify v1.10.0
github.com/stretchr/testify v1.11.1
github.com/testcontainers/testcontainers-go v0.40.0
github.com/testcontainers/testcontainers-go/modules/mysql v0.40.0
github.com/testcontainers/testcontainers-go/modules/postgres v0.40.0
github.com/yuin/goldmark v1.7.13
golang.org/x/crypto v0.42.0
golang.org/x/crypto v0.43.0
golang.org/x/mod v0.28.0
golang.org/x/net v0.43.0
golang.org/x/net v0.45.0
golang.org/x/oauth2 v0.30.0
golang.org/x/sync v0.17.0
google.golang.org/genproto/googleapis/api v0.0.0-20250826171959-ef028d996bc1
@ -35,23 +38,64 @@ require (
require (
cel.dev/expr v0.24.0 // indirect
dario.cat/mergo v1.0.2 // indirect
filippo.io/edwards25519 v1.1.0 // indirect
github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1 // indirect
github.com/Microsoft/go-winio v0.6.2 // indirect
github.com/antlr4-go/antlr/v4 v4.13.1 // indirect
github.com/cenkalti/backoff/v4 v4.3.0 // indirect
github.com/containerd/errdefs v1.0.0 // indirect
github.com/containerd/errdefs/pkg v0.3.0 // indirect
github.com/containerd/log v0.1.0 // indirect
github.com/containerd/platforms v0.2.1 // indirect
github.com/cpuguy83/dockercfg v0.3.2 // indirect
github.com/distribution/reference v0.6.0 // indirect
github.com/docker/docker v28.5.1+incompatible // indirect
github.com/docker/go-connections v0.6.0 // indirect
github.com/docker/go-units v0.5.0 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/ebitengine/purego v0.8.4 // indirect
github.com/felixge/httpsnoop v1.0.4 // indirect
github.com/fsnotify/fsnotify v1.8.0 // indirect
github.com/go-logr/logr v1.4.3 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
github.com/go-ole/go-ole v1.2.6 // indirect
github.com/go-viper/mapstructure/v2 v2.2.1 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/klauspost/compress v1.18.0 // indirect
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 // indirect
github.com/magiconair/properties v1.8.10 // indirect
github.com/moby/docker-image-spec v1.3.1 // indirect
github.com/moby/go-archive v0.1.0 // indirect
github.com/moby/patternmatcher v0.6.0 // indirect
github.com/moby/sys/sequential v0.6.0 // indirect
github.com/moby/sys/user v0.4.0 // indirect
github.com/moby/sys/userns v0.1.0 // indirect
github.com/moby/term v0.5.0 // indirect
github.com/morikuni/aec v1.0.0 // indirect
github.com/ncruces/go-strftime v0.1.9 // indirect
github.com/opencontainers/go-digest v1.0.0 // indirect
github.com/opencontainers/image-spec v1.1.1 // indirect
github.com/pelletier/go-toml/v2 v2.2.3 // indirect
github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/rogpeppe/go-internal v1.11.0 // indirect
github.com/sagikazarmark/locafero v0.7.0 // indirect
github.com/shirou/gopsutil/v4 v4.25.6 // indirect
github.com/sirupsen/logrus v1.9.3 // indirect
github.com/sourcegraph/conc v0.3.0 // indirect
github.com/spf13/afero v1.12.0 // indirect
github.com/spf13/cast v1.7.1 // indirect
github.com/spf13/pflag v1.0.9 // indirect
github.com/stoewer/go-strcase v1.3.1 // indirect
github.com/subosito/gotenv v1.6.0 // indirect
github.com/tklauser/go-sysconf v0.3.12 // indirect
github.com/tklauser/numcpus v0.6.1 // indirect
github.com/yusufpapurcu/wmi v1.2.4 // indirect
go.opentelemetry.io/auto/sdk v1.1.0 // indirect
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.54.0 // indirect
go.opentelemetry.io/otel v1.37.0 // indirect
go.opentelemetry.io/otel/metric v1.37.0 // indirect
go.opentelemetry.io/otel/trace v1.37.0 // indirect
go.uber.org/atomic v1.9.0 // indirect
go.uber.org/multierr v1.9.0 // indirect
golang.org/x/exp v0.0.0-20250819193227-8b4c13bb791b // indirect
@ -86,8 +130,8 @@ require (
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
github.com/valyala/bytebufferpool v1.0.0 // indirect
github.com/valyala/fasttemplate v1.2.2 // indirect
golang.org/x/sys v0.36.0 // indirect
golang.org/x/text v0.29.0 // indirect
golang.org/x/sys v0.37.0 // indirect
golang.org/x/text v0.30.0 // indirect
golang.org/x/time v0.12.0 // indirect
google.golang.org/protobuf v1.36.9
gopkg.in/yaml.v3 v3.0.1 // indirect

144
go.sum
View File

@ -2,8 +2,16 @@ cel.dev/expr v0.24.0 h1:56OvJKSH3hDGL0ml5uSxZmz3/3Pq4tJ+fb1unVLAFcY=
cel.dev/expr v0.24.0/go.mod h1:hLPLo1W4QUmuYdA72RBX06QTs6MXw941piREPl3Yfiw=
connectrpc.com/connect v1.19.1 h1:R5M57z05+90EfEvCY1b7hBxDVOUl45PrtXtAV2fOC14=
connectrpc.com/connect v1.19.1/go.mod h1:tN20fjdGlewnSFeZxLKb0xwIZ6ozc3OQs2hTXy4du9w=
dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8=
dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA=
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6 h1:He8afgbRMd7mFxO99hRNu+6tazq8nFF9lIwo9JFroBk=
github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6/go.mod h1:8o94RPi1/7XTJvwPpRSzSUedZrtlirdB3r9Z20bi2f8=
github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1 h1:UQHMgLO+TxOElx5B5HZ4hJQsoJ/PvUvKRhJHDQXO8P8=
github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E=
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
github.com/antlr4-go/antlr/v4 v4.13.1 h1:SqQKkuVZ+zWkMMNkjy5FZe5mr5WURWnlpmOuzYWrPrQ=
github.com/antlr4-go/antlr/v4 v4.13.1/go.mod h1:GKmUxMtwp6ZgGwZSva4eWPC5mS6vUAmOABFgjdkM7Nw=
github.com/aws/aws-sdk-go-v2 v1.39.2 h1:EJLg8IdbzgeD7xgvZ+I8M1e0fL0ptn/M47lianzth0I=
@ -44,23 +52,52 @@ github.com/aws/aws-sdk-go-v2/service/sts v1.38.6 h1:p3jIvqYwUZgu/XYeI48bJxOhvm47
github.com/aws/aws-sdk-go-v2/service/sts v1.38.6/go.mod h1:WtKK+ppze5yKPkZ0XwqIVWD4beCwv056ZbPQNoeHqM8=
github.com/aws/smithy-go v1.23.0 h1:8n6I3gXzWJB2DxBDnfxgBaSX6oe0d/t10qGz7OKqMCE=
github.com/aws/smithy-go v1.23.0/go.mod h1:t1ufH5HMublsJYulve2RKmHDC15xu1f26kHCp/HgceI=
github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8=
github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE=
github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI=
github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M=
github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE=
github.com/containerd/errdefs/pkg v0.3.0/go.mod h1:NJw6s9HwNuRhnjJhM7pylWwMyAkmCQvQ4GpJHEqRLVk=
github.com/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I=
github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo=
github.com/containerd/platforms v0.2.1 h1:zvwtM3rz2YHPQsF2CHYM8+KtB5dvhISiXh5ZpSBQv6A=
github.com/containerd/platforms v0.2.1/go.mod h1:XHCb+2/hzowdiut9rkudds9bE5yJ7npe7dG/wG+uFPw=
github.com/cpuguy83/dockercfg v0.3.2 h1:DlJTyZGBDlXqUZ2Dk2Q3xHs/FtnooJJVaad2S9GKorA=
github.com/cpuguy83/dockercfg v0.3.2/go.mod h1:sugsbF4//dDlL/i+S+rtpIWp+5h0BHJHfjj5/jFyUJc=
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
github.com/creack/pty v1.1.18 h1:n56/Zwd5o6whRC5PMGretI4IdRLlmBXYNjScPaBgsbY=
github.com/creack/pty v1.1.18/go.mod h1:MOBLtS5ELjhRRrroQr9kyvTxUAFNvYEK993ew/Vr4O4=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/disintegration/imaging v1.6.2 h1:w1LecBlG2Lnp8B3jk5zSuNqd7b4DXhcjwek1ei82L+c=
github.com/disintegration/imaging v1.6.2/go.mod h1:44/5580QXChDfwIclfc/PCwrr44amcmDAg8hxG0Ewe4=
github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk=
github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E=
github.com/docker/docker v28.5.1+incompatible h1:Bm8DchhSD2J6PsFzxC35TZo4TLGR2PdW/E69rU45NhM=
github.com/docker/docker v28.5.1+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk=
github.com/docker/go-connections v0.6.0 h1:LlMG9azAe1TqfR7sO+NJttz1gy6KO7VJBh+pMmjSD94=
github.com/docker/go-connections v0.6.0/go.mod h1:AahvXYshr6JgfUJGdDCs2b5EZG/vmaMAntpSFH5BFKE=
github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4=
github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/ebitengine/purego v0.8.4 h1:CF7LEKg5FFOsASUj0+QwaXf8Ht6TlFxg09+S9wz0omw=
github.com/ebitengine/purego v0.8.4/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
github.com/fsnotify/fsnotify v1.8.0 h1:dAwr6QBTBZIkG8roQaJjGof0pp0EeF+tNV7YBP3F/8M=
github.com/fsnotify/fsnotify v1.8.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
github.com/go-ole/go-ole v1.2.6 h1:/Fpf6oFPoeFik9ty7siob0G6Ke8QvQEuVcuChpwXzpY=
github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0=
github.com/go-sql-driver/mysql v1.9.3 h1:U/N249h2WzJ3Ukj8SowVFjdtZKfu9vlLZxjPXV1aweo=
github.com/go-sql-driver/mysql v1.9.3/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU=
github.com/go-viper/mapstructure/v2 v2.2.1 h1:ZAaOCxANMuZx5RCeg0mBdEZk7DZasvvZIxtHqx8aGss=
@ -71,6 +108,7 @@ github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
github.com/google/cel-go v0.26.1 h1:iPbVVEdkhTX++hpe3lzSk7D3G3QSYqLGoHOcEio+UXQ=
github.com/google/cel-go v0.26.1/go.mod h1:A9O8OU9rdvrK5MQyrqfIxo1a0u4g3sF8KB6PUIaryMM=
github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
@ -83,8 +121,18 @@ github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.2 h1:8Tjv8EJ+pM1xP8mK6egEbD1OgnV
github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.2/go.mod h1:pkJQ2tZHJ0aFOVEEot6oZmaVEZcRme73eIFmhiVuRWs=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a h1:bbPeKD0xmW/Y25WS6cokEszi5g+S0QxI/d45PkRi7Nk=
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
github.com/jackc/pgx/v5 v5.5.4 h1:Xp2aQS8uXButQdnCMWNmvx6UysWQQC+u1EoizjguY+8=
github.com/jackc/pgx/v5 v5.5.4/go.mod h1:ez9gk+OAat140fv9ErkZDYFWmXLfV+++K0uAOiwgm1A=
github.com/jackc/puddle/v2 v2.2.1 h1:RhxXJtFG022u4ibrCSMSiu5aOq1i77R3OHKNJj77OAk=
github.com/jackc/puddle/v2 v2.2.1/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
@ -97,12 +145,40 @@ github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
github.com/lithammer/shortuuid/v4 v4.2.0 h1:LMFOzVB3996a7b8aBuEXxqOBflbfPQAiVzkIcHO0h8c=
github.com/lithammer/shortuuid/v4 v4.2.0/go.mod h1:D5noHZ2oFw/YaKCfGy0YxyE7M0wMbezmMjPdhyEFe6Y=
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 h1:6E+4a0GO5zZEnZ81pIr0yLvtUWk2if982qA3F3QD6H4=
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0/go.mod h1:zJYVVT2jmtg6P3p1VtQj7WsuWi/y4VnjVBn7F8KPB3I=
github.com/magiconair/properties v1.8.10 h1:s31yESBquKXCV9a/ScB3ESkOjUYYv+X0rg8SYxI99mE=
github.com/magiconair/properties v1.8.10/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0=
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mdelapenya/tlscert v0.2.0 h1:7H81W6Z/4weDvZBNOfQte5GpIMo0lGYEeWbkGp5LJHI=
github.com/mdelapenya/tlscert v0.2.0/go.mod h1:O4njj3ELLnJjGdkN7M/vIVCpZ+Cf0L6muqOG4tLSl8o=
github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0=
github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo=
github.com/moby/go-archive v0.1.0 h1:Kk/5rdW/g+H8NHdJW2gsXyZ7UnzvJNOy6VKJqueWdcQ=
github.com/moby/go-archive v0.1.0/go.mod h1:G9B+YoujNohJmrIYFBpSd54GTUB4lt9S+xVQvsJyFuo=
github.com/moby/patternmatcher v0.6.0 h1:GmP9lR19aU5GqSSFko+5pRqHi+Ohk1O69aFiKkVGiPk=
github.com/moby/patternmatcher v0.6.0/go.mod h1:hDPoyOpDY7OrrMDLaYoY3hf52gNCR/YOUYxkhApJIxc=
github.com/moby/sys/atomicwriter v0.1.0 h1:kw5D/EqkBwsBFi0ss9v1VG3wIkVhzGvLklJ+w3A14Sw=
github.com/moby/sys/atomicwriter v0.1.0/go.mod h1:Ul8oqv2ZMNHOceF643P6FKPXeCmYtlQMvpizfsSoaWs=
github.com/moby/sys/sequential v0.6.0 h1:qrx7XFUd/5DxtqcoH1h438hF5TmOvzC/lspjy7zgvCU=
github.com/moby/sys/sequential v0.6.0/go.mod h1:uyv8EUTrca5PnDsdMGXhZe6CCe8U/UiTWd+lL+7b/Ko=
github.com/moby/sys/user v0.4.0 h1:jhcMKit7SA80hivmFJcbB1vqmw//wU61Zdui2eQXuMs=
github.com/moby/sys/user v0.4.0/go.mod h1:bG+tYYYJgaMtRKgEmuueC0hJEAZWwtIbZTB+85uoHjs=
github.com/moby/sys/userns v0.1.0 h1:tVLXkFOxVu9A64/yh59slHVv9ahO9UIev4JZusOLG/g=
github.com/moby/sys/userns v0.1.0/go.mod h1:IHUYgu/kao6N8YZlp9Cf444ySSvCmDlmzUcYfDHOl28=
github.com/moby/term v0.5.0 h1:xt8Q1nalod/v7BqbG21f8mQPqH+xAaC9C3N3wfWbVP0=
github.com/moby/term v0.5.0/go.mod h1:8FzsFHVUBGZdbDsJw/ot+X+d5HLUbvklYLJ9uGfcI3Y=
github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A=
github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc=
github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U=
github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040=
github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M=
github.com/pelletier/go-toml/v2 v2.2.3 h1:YmeHyLY8mFWbdkNWwpr+qIL2bEqT0o95WSdkNHvL12M=
github.com/pelletier/go-toml/v2 v2.2.3/go.mod h1:MfCQTFTvCcUyyvvwm1+G6H/jORL20Xlb6rzQu9GuUkc=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
@ -110,13 +186,19 @@ github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINE
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c h1:ncq/mPwQF4JjgDlrVEn3C11VoGHZN7m8qihwgMEtzYw=
github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/rogpeppe/go-internal v1.11.0 h1:cWPaGQEPrBb5/AsnsZesgZZ9yb1OQ+GOISoDNXVBh4M=
github.com/rogpeppe/go-internal v1.11.0/go.mod h1:ddIwULY96R17DhadqLgMfk9H9tvdUzkipdSkR5nkCZA=
github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII=
github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/sagikazarmark/locafero v0.7.0 h1:5MqpDsTGNDhY8sGp0Aowyf0qKsPrhewaLSsFaodPcyo=
github.com/sagikazarmark/locafero v0.7.0/go.mod h1:2za3Cg5rMaTMoG/2Ulr9AwtFaIppKXTRYnozin4aB5k=
github.com/shirou/gopsutil/v4 v4.25.6 h1:kLysI2JsKorfaFPcYmcJqbzROzsBWEOAtw6A7dIfqXs=
github.com/shirou/gopsutil/v4 v4.25.6/go.mod h1:PfybzyydfZcN+JMMjkF6Zb8Mq1A/VcogFFg7hj50W9c=
github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
github.com/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9ySo=
github.com/sourcegraph/conc v0.3.0/go.mod h1:Sdozi7LEKbFPqYX2/J+iBAM6HpqSLTASQIKqDmF7Mt0=
github.com/spf13/afero v1.12.0 h1:UcOPyRBYczmFn6yvphxkn9ZEOY65cpwGKb5mL36mrqs=
@ -134,24 +216,45 @@ github.com/stoewer/go-strcase v1.3.1/go.mod h1:fAH5hQ5pehh+j3nZfvwdk2RgEgQjAoM8w
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
github.com/testcontainers/testcontainers-go v0.40.0 h1:pSdJYLOVgLE8YdUY2FHQ1Fxu+aMnb6JfVz1mxk7OeMU=
github.com/testcontainers/testcontainers-go v0.40.0/go.mod h1:FSXV5KQtX2HAMlm7U3APNyLkkap35zNLxukw9oBi/MY=
github.com/testcontainers/testcontainers-go/modules/mysql v0.40.0 h1:P9Txfy5Jothx2wFdcus0QoSmX/PKSIXZxrTbZPVJswA=
github.com/testcontainers/testcontainers-go/modules/mysql v0.40.0/go.mod h1:oZPHHqJqXG7FD8OB/yWH7gLnDvZUlFHAVJNrGftL+eg=
github.com/testcontainers/testcontainers-go/modules/postgres v0.40.0 h1:s2bIayFXlbDFexo96y+htn7FzuhpXLYJNnIuglNKqOk=
github.com/testcontainers/testcontainers-go/modules/postgres v0.40.0/go.mod h1:h+u/2KoREGTnTl9UwrQ/g+XhasAT8E6dClclAADeXoQ=
github.com/tklauser/go-sysconf v0.3.12 h1:0QaGUFOdQaIVdPgfITYzaTegZvdCjmYO52cSFAEVmqU=
github.com/tklauser/go-sysconf v0.3.12/go.mod h1:Ho14jnntGE1fpdOqQEEaiKRpvIavV0hSfmBq8nJbHYI=
github.com/tklauser/numcpus v0.6.1 h1:ng9scYS7az0Bk4OZLvrNXNSAO2Pxr1XXRAPyjhIx+Fk=
github.com/tklauser/numcpus v0.6.1/go.mod h1:1XfjsgE2zo8GVw7POkMbHENHzVg3GzmoZ9fESEdAacY=
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
github.com/valyala/fasttemplate v1.2.2 h1:lxLXG0uE3Qnshl9QyaK6XJxMXlQZELvChBOCmQD0Loo=
github.com/valyala/fasttemplate v1.2.2/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ=
github.com/yuin/goldmark v1.7.13 h1:GPddIs617DnBLFFVJFgpo1aBfe/4xcvMc3SB5t/D0pA=
github.com/yuin/goldmark v1.7.13/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0=
github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA=
go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.54.0 h1:TT4fX+nBOA/+LUkobKGW1ydGcn+G3vRw9+g5HwCphpk=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.54.0/go.mod h1:L7UH0GbB0p47T4Rri3uHjbpCFYrVrwc1I25QhNPiGK8=
go.opentelemetry.io/otel v1.37.0 h1:9zhNfelUvx0KBfu/gb+ZgeAfAgtWrfHJZcAqFC228wQ=
go.opentelemetry.io/otel v1.37.0/go.mod h1:ehE/umFRLnuLa/vSccNq9oS1ErUlkkK71gMcN34UG8I=
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.19.0 h1:Mne5On7VWdx7omSrSSZvM4Kw7cS7NQkOOmLcgscI51U=
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.19.0/go.mod h1:IPtUMKL4O3tH5y+iXVyAXqpAwMuzC1IrxVS81rummfE=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.19.0 h1:IeMeyr1aBvBiPVYihXIaeIZba6b8E1bYp7lbdxK8CQg=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.19.0/go.mod h1:oVdCUtjq9MK9BlS7TtucsQwUcXcymNiEDjgDD2jMtZU=
go.opentelemetry.io/otel/metric v1.37.0 h1:mvwbQS5m0tbmqML4NqK+e3aDiO02vsf/WgbsdpcPoZE=
go.opentelemetry.io/otel/metric v1.37.0/go.mod h1:04wGrZurHYKOc+RKeye86GwKiTb9FKm1WHtO+4EVr2E=
go.opentelemetry.io/otel/sdk v1.37.0 h1:ItB0QUqnjesGRvNcmAcU0LyvkVyGJ2xftD29bWdDvKI=
@ -160,12 +263,14 @@ go.opentelemetry.io/otel/sdk/metric v1.37.0 h1:90lI228XrB9jCMuSdA0673aubgRobVZFh
go.opentelemetry.io/otel/sdk/metric v1.37.0/go.mod h1:cNen4ZWfiD37l5NhS+Keb5RXVWZWpRE+9WyVCpbo5ps=
go.opentelemetry.io/otel/trace v1.37.0 h1:HLdcFNbRQBE2imdSEgm/kwqmQj1Or1l/7bW6mxVK7z4=
go.opentelemetry.io/otel/trace v1.37.0/go.mod h1:TlgrlQ+PtQO5XFerSPUYG0JSgGyryXewPGyayAWSBS0=
go.opentelemetry.io/proto/otlp v1.0.0 h1:T0TX0tmXU8a3CbNXzEKGeU5mIVOdf0oykP+u2lIVU/I=
go.opentelemetry.io/proto/otlp v1.0.0/go.mod h1:Sy6pihPLfYHkr3NkUbEhGHFhINUSI/v80hjKIs5JXpM=
go.uber.org/atomic v1.9.0 h1:ECmE8Bn/WFTYwEW/bpKD3M8VtR/zQVbavAoalC1PYyE=
go.uber.org/atomic v1.9.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc=
go.uber.org/multierr v1.9.0 h1:7fIwc/ZtS0q++VgcfqFDxSBZVv/Xo49/SYnDFupUwlI=
go.uber.org/multierr v1.9.0/go.mod h1:X2jQV1h+kxSjClGpnseKVIxpmcjrj7MNnI0bnlfKTVQ=
golang.org/x/crypto v0.42.0 h1:chiH31gIWm57EkTXpwnqf8qeuMUi0yekh6mT2AvFlqI=
golang.org/x/crypto v0.42.0/go.mod h1:4+rDnOTJhQCx2q7/j6rAN5XDw8kPjeaXEUR2eL94ix8=
golang.org/x/crypto v0.43.0 h1:dduJYIi3A3KOfdGOHX8AVZ/jGiyPa3IbBozJ5kNuE04=
golang.org/x/crypto v0.43.0/go.mod h1:BFbav4mRNlXJL4wNeejLpWxB7wMbc79PdRGhWKncxR0=
golang.org/x/exp v0.0.0-20250819193227-8b4c13bb791b h1:DXr+pvt3nC887026GRP39Ej11UATqWDmWuS99x26cD0=
golang.org/x/exp v0.0.0-20250819193227-8b4c13bb791b/go.mod h1:4QTo5u+SEIbbKW1RacMZq1YEfOBqeXa19JeshGi+zc4=
golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
@ -173,22 +278,31 @@ golang.org/x/image v0.30.0 h1:jD5RhkmVAnjqaCUXfbGBrn3lpxbknfN9w2UhHHU+5B4=
golang.org/x/image v0.30.0/go.mod h1:SAEUTxCCMWSrJcCy/4HwavEsfZZJlYxeHLc6tTiAe/c=
golang.org/x/mod v0.28.0 h1:gQBtGhjxykdjY9YhZpSlZIsbnaE2+PgjfLWUQTnoZ1U=
golang.org/x/mod v0.28.0/go.mod h1:yfB/L0NOf/kmEbXjzCPOx1iK1fRutOydrCMsqRhEBxI=
golang.org/x/net v0.43.0 h1:lat02VYK2j4aLzMzecihNvTlJNQUq316m2Mr9rnM6YE=
golang.org/x/net v0.43.0/go.mod h1:vhO1fvI4dGsIjh73sWfUVjj3N7CA9WkKJNQm2svM6Jg=
golang.org/x/net v0.45.0 h1:RLBg5JKixCy82FtLJpeNlVM0nrSqpCRYzVU1n8kj0tM=
golang.org/x/net v0.45.0/go.mod h1:ECOoLqd5U3Lhyeyo/QDCEVQ4sNgYsqvCZ722XogGieY=
golang.org/x/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI=
golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU=
golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k=
golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ=
golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/term v0.36.0 h1:zMPR+aF8gfksFprF/Nc/rd1wRS1EI6nDBGyWAvDzx2Q=
golang.org/x/term v0.36.0/go.mod h1:Qu394IJq6V6dCBRgwqshf3mPF85AqzYEzofzRdZkWss=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk=
golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4=
golang.org/x/text v0.30.0 h1:yznKA/E9zq54KzlzBEAWn1NXSQ8DIp/NYMy88xJjl4k=
golang.org/x/text v0.30.0/go.mod h1:yDdHFIX9t+tORqspjENWgzaCVXgk0yYnYuSZ8UzzBVM=
golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE=
golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg=
golang.org/x/tools v0.36.0 h1:kWS0uv/zsvHEle1LbV5LE8QujrxB3wfQyxHfhOk0Qkg=
golang.org/x/tools v0.36.0/go.mod h1:WBDiHKJK8YgLHlcQPYQzNCkUxUypCaa5ZegCVutKm+s=
golang.org/x/tools v0.37.0 h1:DVSRzp7FwePZW356yEAChSdNcQo6Nsp+fex1SUW09lE=
golang.org/x/tools v0.37.0/go.mod h1:MBN5QPQtLMHVdvsbtarmTNukZDdgwdwlO5qGacAzF0w=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
google.golang.org/genproto/googleapis/api v0.0.0-20250826171959-ef028d996bc1 h1:APHvLLYBhtZvsbnpkfknDZ7NyH4z5+ub/I0u8L3Oz6g=
@ -205,6 +319,8 @@ gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EV
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q=
gotest.tools/v3 v3.5.2/go.mod h1:LtdLGcnqToBH83WByAAi/wiwSFCArdFIUV/xxN4pcjA=
modernc.org/cc/v4 v4.26.4 h1:jPhG8oNjtTYuP2FA4YefTJ/wioNUGALmGuEWt7SUR6s=
modernc.org/cc/v4 v4.26.4/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
modernc.org/ccgo/v4 v4.28.1 h1:wPKYn5EC/mYTqBO373jKjvX2n+3+aK7+sICCv4Fjy1A=

View File

@ -125,8 +125,11 @@ func NewSchema() Schema {
Type: FieldTypeTimestamp,
Column: Column{Table: "memo", Name: "created_ts"},
Expressions: map[DialectName]string{
DialectMySQL: "UNIX_TIMESTAMP(%s)",
DialectPostgres: "EXTRACT(EPOCH FROM TO_TIMESTAMP(%s))",
// MySQL stores created_ts as TIMESTAMP, needs conversion to epoch
DialectMySQL: "UNIX_TIMESTAMP(%s)",
// PostgreSQL and SQLite store created_ts as BIGINT (epoch), no conversion needed
DialectPostgres: "%s",
DialectSQLite: "%s",
},
},
"updated_ts": {
@ -135,8 +138,11 @@ func NewSchema() Schema {
Type: FieldTypeTimestamp,
Column: Column{Table: "memo", Name: "updated_ts"},
Expressions: map[DialectName]string{
DialectMySQL: "UNIX_TIMESTAMP(%s)",
DialectPostgres: "EXTRACT(EPOCH FROM TO_TIMESTAMP(%s))",
// MySQL stores updated_ts as TIMESTAMP, needs conversion to epoch
DialectMySQL: "UNIX_TIMESTAMP(%s)",
// PostgreSQL and SQLite store updated_ts as BIGINT (epoch), no conversion needed
DialectPostgres: "%s",
DialectSQLite: "%s",
},
},
"pinned": {
@ -267,15 +273,18 @@ func NewAttachmentSchema() Schema {
Type: FieldTypeTimestamp,
Column: Column{Table: "resource", Name: "created_ts"},
Expressions: map[DialectName]string{
DialectMySQL: "UNIX_TIMESTAMP(%s)",
DialectPostgres: "EXTRACT(EPOCH FROM TO_TIMESTAMP(%s))",
// MySQL stores created_ts as TIMESTAMP, needs conversion to epoch
DialectMySQL: "UNIX_TIMESTAMP(%s)",
// PostgreSQL and SQLite store created_ts as BIGINT (epoch), no conversion needed
DialectPostgres: "%s",
DialectSQLite: "%s",
},
},
"memo": {
Name: "memo",
"memo_id": {
Name: "memo_id",
Kind: FieldKindScalar,
Type: FieldTypeString,
Column: Column{Table: "resource", Name: "memo_uid"},
Type: FieldTypeInt,
Column: Column{Table: "resource", Name: "memo_id"},
Expressions: map[DialectName]string{},
AllowedComparisonOps: map[ComparisonOperator]bool{
CompareEq: true,
@ -288,7 +297,7 @@ func NewAttachmentSchema() Schema {
cel.Variable("filename", cel.StringType),
cel.Variable("mime_type", cel.StringType),
cel.Variable("create_time", cel.IntType),
cel.Variable("memo", cel.StringType),
cel.Variable("memo_id", cel.IntType),
nowFunction,
}

View File

@ -50,7 +50,7 @@ func (d *DB) Close() error {
func (d *DB) IsInitialized(ctx context.Context) (bool, error) {
var exists bool
err := d.db.QueryRowContext(ctx, "SELECT EXISTS (SELECT 1 FROM information_schema.tables WHERE TABLE_NAME = 'memo' AND TABLE_TYPE = 'BASE TABLE')").Scan(&exists)
err := d.db.QueryRowContext(ctx, "SELECT EXISTS (SELECT 1 FROM information_schema.tables WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'memo' AND TABLE_TYPE = 'BASE TABLE')").Scan(&exists)
if err != nil {
return false, errors.Wrap(err, "failed to check if database is initialized")
}

View File

@ -93,7 +93,7 @@ func TestConvertExprToSQL(t *testing.T) {
},
{
filter: `created_ts > now() - 60 * 60 * 24`,
want: "EXTRACT(EPOCH FROM TO_TIMESTAMP(memo.created_ts)) > $1",
want: "memo.created_ts > $1",
args: []any{time.Now().Unix() - 60*60*24},
},
{

View File

@ -49,7 +49,7 @@ func (d *DB) Close() error {
func (d *DB) IsInitialized(ctx context.Context) (bool, error) {
var exists bool
err := d.db.QueryRowContext(ctx, "SELECT EXISTS (SELECT 1 FROM information_schema.tables WHERE table_name = 'memo' AND table_type = 'BASE TABLE')").Scan(&exists)
err := d.db.QueryRowContext(ctx, "SELECT EXISTS (SELECT 1 FROM information_schema.tables WHERE table_catalog = current_database() AND table_name = 'memo' AND table_type = 'BASE TABLE')").Scan(&exists)
if err != nil {
return false, errors.Wrap(err, "failed to check if database is initialized")
}

View File

@ -32,3 +32,70 @@ func TestActivityStore(t *testing.T) {
require.Equal(t, activity, activities[0])
ts.Close()
}
func TestActivityGetByID(t *testing.T) {
ctx := context.Background()
ts := NewTestingStore(ctx, t)
user, err := createTestingHostUser(ctx, ts)
require.NoError(t, err)
activity, err := ts.CreateActivity(ctx, &store.Activity{
CreatorID: user.ID,
Type: store.ActivityTypeMemoComment,
Level: store.ActivityLevelInfo,
Payload: &storepb.ActivityPayload{},
})
require.NoError(t, err)
// Get activity by ID
found, err := ts.GetActivity(ctx, &store.FindActivity{ID: &activity.ID})
require.NoError(t, err)
require.NotNil(t, found)
require.Equal(t, activity.ID, found.ID)
// Get non-existent activity
nonExistentID := int32(99999)
notFound, err := ts.GetActivity(ctx, &store.FindActivity{ID: &nonExistentID})
require.NoError(t, err)
require.Nil(t, notFound)
ts.Close()
}
func TestActivityListMultiple(t *testing.T) {
ctx := context.Background()
ts := NewTestingStore(ctx, t)
user, err := createTestingHostUser(ctx, ts)
require.NoError(t, err)
// Create multiple activities
_, err = ts.CreateActivity(ctx, &store.Activity{
CreatorID: user.ID,
Type: store.ActivityTypeMemoComment,
Level: store.ActivityLevelInfo,
Payload: &storepb.ActivityPayload{},
})
require.NoError(t, err)
_, err = ts.CreateActivity(ctx, &store.Activity{
CreatorID: user.ID,
Type: store.ActivityTypeMemoComment,
Level: store.ActivityLevelInfo,
Payload: &storepb.ActivityPayload{},
})
require.NoError(t, err)
// List all activities
allActivities, err := ts.ListActivities(ctx, &store.FindActivity{})
require.NoError(t, err)
require.Equal(t, 2, len(allActivities))
// List by type
commentType := store.ActivityTypeMemoComment
commentActivities, err := ts.ListActivities(ctx, &store.FindActivity{Type: &commentType})
require.NoError(t, err)
require.Equal(t, 2, len(commentActivities))
require.Equal(t, store.ActivityTypeMemoComment, commentActivities[0].Type)
ts.Close()
}

View File

@ -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)
}

View File

@ -2,6 +2,7 @@ package test
import (
"context"
"fmt"
"testing"
"github.com/lithammer/shortuuid/v4"
@ -120,3 +121,121 @@ func TestAttachmentStoreWithFilter(t *testing.T) {
ts.Close()
}
func TestAttachmentUpdate(t *testing.T) {
ctx := context.Background()
ts := NewTestingStore(ctx, t)
attachment, err := ts.CreateAttachment(ctx, &store.Attachment{
UID: shortuuid.New(),
CreatorID: 101,
Filename: "original.png",
Blob: []byte("test"),
Type: "image/png",
Size: 1000,
})
require.NoError(t, err)
// Update filename
newFilename := "updated.png"
err = ts.UpdateAttachment(ctx, &store.UpdateAttachment{
ID: attachment.ID,
Filename: &newFilename,
})
require.NoError(t, err)
// Verify update
found, err := ts.GetAttachment(ctx, &store.FindAttachment{ID: &attachment.ID})
require.NoError(t, err)
require.Equal(t, newFilename, found.Filename)
ts.Close()
}
func TestAttachmentGetByUID(t *testing.T) {
ctx := context.Background()
ts := NewTestingStore(ctx, t)
uid := shortuuid.New()
_, err := ts.CreateAttachment(ctx, &store.Attachment{
UID: uid,
CreatorID: 101,
Filename: "test.png",
Blob: []byte("test"),
Type: "image/png",
Size: 1000,
})
require.NoError(t, err)
// Get by UID
found, err := ts.GetAttachment(ctx, &store.FindAttachment{UID: &uid})
require.NoError(t, err)
require.NotNil(t, found)
require.Equal(t, uid, found.UID)
// Get non-existent UID
nonExistentUID := "non-existent-uid"
notFound, err := ts.GetAttachment(ctx, &store.FindAttachment{UID: &nonExistentUID})
require.NoError(t, err)
require.Nil(t, notFound)
ts.Close()
}
func TestAttachmentListWithPagination(t *testing.T) {
ctx := context.Background()
ts := NewTestingStore(ctx, t)
// Create 5 attachments
for i := 0; i < 5; i++ {
_, err := ts.CreateAttachment(ctx, &store.Attachment{
UID: shortuuid.New(),
CreatorID: 101,
Filename: fmt.Sprintf("test%d.png", i),
Blob: []byte("test"),
Type: "image/png",
Size: int64(1000 + i),
})
require.NoError(t, err)
}
// Test limit
limit := 3
attachments, err := ts.ListAttachments(ctx, &store.FindAttachment{
CreatorID: &[]int32{101}[0],
Limit: &limit,
})
require.NoError(t, err)
require.Equal(t, 3, len(attachments))
// Test offset
offset := 2
offsetAttachments, err := ts.ListAttachments(ctx, &store.FindAttachment{
CreatorID: &[]int32{101}[0],
Limit: &limit,
Offset: &offset,
})
require.NoError(t, err)
require.Equal(t, 3, len(offsetAttachments))
ts.Close()
}
func TestAttachmentInvalidUID(t *testing.T) {
ctx := context.Background()
ts := NewTestingStore(ctx, t)
// Create with invalid UID (contains spaces)
_, err := ts.CreateAttachment(ctx, &store.Attachment{
UID: "invalid uid with spaces",
CreatorID: 101,
Filename: "test.png",
Blob: []byte("test"),
Type: "image/png",
Size: 1000,
})
require.Error(t, err)
require.Contains(t, err.Error(), "invalid uid")
ts.Close()
}

192
store/test/containers.go Normal file
View File

@ -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)
}
}

View File

@ -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
}

View File

@ -29,3 +29,220 @@ func TestInstanceSettingV1Store(t *testing.T) {
require.Equal(t, instanceSetting, setting)
ts.Close()
}
func TestInstanceSettingGetNonExistent(t *testing.T) {
ctx := context.Background()
ts := NewTestingStore(ctx, t)
// Get non-existent setting
setting, err := ts.GetInstanceSetting(ctx, &store.FindInstanceSetting{
Name: storepb.InstanceSettingKey_STORAGE.String(),
})
require.NoError(t, err)
require.Nil(t, setting)
ts.Close()
}
func TestInstanceSettingUpsertUpdate(t *testing.T) {
ctx := context.Background()
ts := NewTestingStore(ctx, t)
// Create setting
_, err := ts.UpsertInstanceSetting(ctx, &storepb.InstanceSetting{
Key: storepb.InstanceSettingKey_GENERAL,
Value: &storepb.InstanceSetting_GeneralSetting{
GeneralSetting: &storepb.InstanceGeneralSetting{
AdditionalScript: "console.log('v1')",
},
},
})
require.NoError(t, err)
// Update setting
_, err = ts.UpsertInstanceSetting(ctx, &storepb.InstanceSetting{
Key: storepb.InstanceSettingKey_GENERAL,
Value: &storepb.InstanceSetting_GeneralSetting{
GeneralSetting: &storepb.InstanceGeneralSetting{
AdditionalScript: "console.log('v2')",
},
},
})
require.NoError(t, err)
// Verify update
setting, err := ts.GetInstanceSetting(ctx, &store.FindInstanceSetting{
Name: storepb.InstanceSettingKey_GENERAL.String(),
})
require.NoError(t, err)
require.Equal(t, "console.log('v2')", setting.GetGeneralSetting().AdditionalScript)
// Verify only one setting exists
list, err := ts.ListInstanceSettings(ctx, &store.FindInstanceSetting{
Name: storepb.InstanceSettingKey_GENERAL.String(),
})
require.NoError(t, err)
require.Equal(t, 1, len(list))
ts.Close()
}
func TestInstanceSettingBasicSetting(t *testing.T) {
ctx := context.Background()
ts := NewTestingStore(ctx, t)
// Get default basic setting (should return empty defaults)
basicSetting, err := ts.GetInstanceBasicSetting(ctx)
require.NoError(t, err)
require.NotNil(t, basicSetting)
// Set basic setting
_, err = ts.UpsertInstanceSetting(ctx, &storepb.InstanceSetting{
Key: storepb.InstanceSettingKey_BASIC,
Value: &storepb.InstanceSetting_BasicSetting{
BasicSetting: &storepb.InstanceBasicSetting{
SecretKey: "my-secret-key",
},
},
})
require.NoError(t, err)
// Verify
basicSetting, err = ts.GetInstanceBasicSetting(ctx)
require.NoError(t, err)
require.Equal(t, "my-secret-key", basicSetting.SecretKey)
ts.Close()
}
func TestInstanceSettingGeneralSetting(t *testing.T) {
ctx := context.Background()
ts := NewTestingStore(ctx, t)
// Get default general setting
generalSetting, err := ts.GetInstanceGeneralSetting(ctx)
require.NoError(t, err)
require.NotNil(t, generalSetting)
// Set general setting
_, err = ts.UpsertInstanceSetting(ctx, &storepb.InstanceSetting{
Key: storepb.InstanceSettingKey_GENERAL,
Value: &storepb.InstanceSetting_GeneralSetting{
GeneralSetting: &storepb.InstanceGeneralSetting{
AdditionalScript: "console.log('test')",
AdditionalStyle: "body { color: red; }",
},
},
})
require.NoError(t, err)
// Verify
generalSetting, err = ts.GetInstanceGeneralSetting(ctx)
require.NoError(t, err)
require.Equal(t, "console.log('test')", generalSetting.AdditionalScript)
require.Equal(t, "body { color: red; }", generalSetting.AdditionalStyle)
ts.Close()
}
func TestInstanceSettingMemoRelatedSetting(t *testing.T) {
ctx := context.Background()
ts := NewTestingStore(ctx, t)
// Get default memo related setting (should have defaults)
memoSetting, err := ts.GetInstanceMemoRelatedSetting(ctx)
require.NoError(t, err)
require.NotNil(t, memoSetting)
require.GreaterOrEqual(t, memoSetting.ContentLengthLimit, int32(store.DefaultContentLengthLimit))
require.NotEmpty(t, memoSetting.Reactions)
// Set custom memo related setting
customReactions := []string{"👍", "👎", "🚀"}
_, err = ts.UpsertInstanceSetting(ctx, &storepb.InstanceSetting{
Key: storepb.InstanceSettingKey_MEMO_RELATED,
Value: &storepb.InstanceSetting_MemoRelatedSetting{
MemoRelatedSetting: &storepb.InstanceMemoRelatedSetting{
ContentLengthLimit: 16384,
Reactions: customReactions,
},
},
})
require.NoError(t, err)
// Verify
memoSetting, err = ts.GetInstanceMemoRelatedSetting(ctx)
require.NoError(t, err)
require.Equal(t, int32(16384), memoSetting.ContentLengthLimit)
require.Equal(t, customReactions, memoSetting.Reactions)
ts.Close()
}
func TestInstanceSettingStorageSetting(t *testing.T) {
ctx := context.Background()
ts := NewTestingStore(ctx, t)
// Get default storage setting (should have defaults)
storageSetting, err := ts.GetInstanceStorageSetting(ctx)
require.NoError(t, err)
require.NotNil(t, storageSetting)
require.Equal(t, storepb.InstanceStorageSetting_DATABASE, storageSetting.StorageType)
require.Equal(t, int64(30), storageSetting.UploadSizeLimitMb)
require.Equal(t, "assets/{timestamp}_{filename}", storageSetting.FilepathTemplate)
// Set custom storage setting
_, err = ts.UpsertInstanceSetting(ctx, &storepb.InstanceSetting{
Key: storepb.InstanceSettingKey_STORAGE,
Value: &storepb.InstanceSetting_StorageSetting{
StorageSetting: &storepb.InstanceStorageSetting{
StorageType: storepb.InstanceStorageSetting_LOCAL,
UploadSizeLimitMb: 100,
FilepathTemplate: "uploads/{date}/{filename}",
},
},
})
require.NoError(t, err)
// Verify
storageSetting, err = ts.GetInstanceStorageSetting(ctx)
require.NoError(t, err)
require.Equal(t, storepb.InstanceStorageSetting_LOCAL, storageSetting.StorageType)
require.Equal(t, int64(100), storageSetting.UploadSizeLimitMb)
require.Equal(t, "uploads/{date}/{filename}", storageSetting.FilepathTemplate)
ts.Close()
}
func TestInstanceSettingListAll(t *testing.T) {
ctx := context.Background()
ts := NewTestingStore(ctx, t)
// Count initial settings
initialList, err := ts.ListInstanceSettings(ctx, &store.FindInstanceSetting{})
require.NoError(t, err)
initialCount := len(initialList)
// Create multiple settings
_, err = ts.UpsertInstanceSetting(ctx, &storepb.InstanceSetting{
Key: storepb.InstanceSettingKey_GENERAL,
Value: &storepb.InstanceSetting_GeneralSetting{
GeneralSetting: &storepb.InstanceGeneralSetting{},
},
})
require.NoError(t, err)
_, err = ts.UpsertInstanceSetting(ctx, &storepb.InstanceSetting{
Key: storepb.InstanceSettingKey_STORAGE,
Value: &storepb.InstanceSetting_StorageSetting{
StorageSetting: &storepb.InstanceStorageSetting{},
},
})
require.NoError(t, err)
// List all - should have 2 more than initial
list, err := ts.ListInstanceSettings(ctx, &store.FindInstanceSetting{})
require.NoError(t, err)
require.Equal(t, initialCount+2, len(list))
ts.Close()
}

50
store/test/main_test.go Normal file
View File

@ -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")
}

View File

@ -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)
}

View File

@ -60,3 +60,182 @@ func TestMemoRelationStore(t *testing.T) {
require.NoError(t, err)
ts.Close()
}
func TestMemoRelationListByMemoID(t *testing.T) {
ctx := context.Background()
ts := NewTestingStore(ctx, t)
user, err := createTestingHostUser(ctx, ts)
require.NoError(t, err)
// Create main memo
mainMemo, err := ts.CreateMemo(ctx, &store.Memo{
UID: "main-memo",
CreatorID: user.ID,
Content: "main memo content",
Visibility: store.Public,
})
require.NoError(t, err)
// Create related memos
relatedMemo1, err := ts.CreateMemo(ctx, &store.Memo{
UID: "related-memo-1",
CreatorID: user.ID,
Content: "related memo 1 content",
Visibility: store.Public,
})
require.NoError(t, err)
relatedMemo2, err := ts.CreateMemo(ctx, &store.Memo{
UID: "related-memo-2",
CreatorID: user.ID,
Content: "related memo 2 content",
Visibility: store.Public,
})
require.NoError(t, err)
// Create relations
_, err = ts.UpsertMemoRelation(ctx, &store.MemoRelation{
MemoID: mainMemo.ID,
RelatedMemoID: relatedMemo1.ID,
Type: store.MemoRelationReference,
})
require.NoError(t, err)
_, err = ts.UpsertMemoRelation(ctx, &store.MemoRelation{
MemoID: mainMemo.ID,
RelatedMemoID: relatedMemo2.ID,
Type: store.MemoRelationComment,
})
require.NoError(t, err)
// List by memo ID
relations, err := ts.ListMemoRelations(ctx, &store.FindMemoRelation{
MemoID: &mainMemo.ID,
})
require.NoError(t, err)
require.Equal(t, 2, len(relations))
// List by type
refType := store.MemoRelationReference
refRelations, err := ts.ListMemoRelations(ctx, &store.FindMemoRelation{
MemoID: &mainMemo.ID,
Type: &refType,
})
require.NoError(t, err)
require.Equal(t, 1, len(refRelations))
require.Equal(t, store.MemoRelationReference, refRelations[0].Type)
// List by related memo ID
relations, err = ts.ListMemoRelations(ctx, &store.FindMemoRelation{
RelatedMemoID: &relatedMemo1.ID,
})
require.NoError(t, err)
require.Equal(t, 1, len(relations))
ts.Close()
}
func TestMemoRelationDelete(t *testing.T) {
ctx := context.Background()
ts := NewTestingStore(ctx, t)
user, err := createTestingHostUser(ctx, ts)
require.NoError(t, err)
// Create memos
mainMemo, err := ts.CreateMemo(ctx, &store.Memo{
UID: "main-memo",
CreatorID: user.ID,
Content: "main memo content",
Visibility: store.Public,
})
require.NoError(t, err)
relatedMemo, err := ts.CreateMemo(ctx, &store.Memo{
UID: "related-memo",
CreatorID: user.ID,
Content: "related memo content",
Visibility: store.Public,
})
require.NoError(t, err)
// Create relation
_, err = ts.UpsertMemoRelation(ctx, &store.MemoRelation{
MemoID: mainMemo.ID,
RelatedMemoID: relatedMemo.ID,
Type: store.MemoRelationReference,
})
require.NoError(t, err)
// Verify relation exists
relations, err := ts.ListMemoRelations(ctx, &store.FindMemoRelation{
MemoID: &mainMemo.ID,
})
require.NoError(t, err)
require.Equal(t, 1, len(relations))
// Delete relation by memo ID
relType := store.MemoRelationReference
err = ts.DeleteMemoRelation(ctx, &store.DeleteMemoRelation{
MemoID: &mainMemo.ID,
RelatedMemoID: &relatedMemo.ID,
Type: &relType,
})
require.NoError(t, err)
// Verify relation is deleted
relations, err = ts.ListMemoRelations(ctx, &store.FindMemoRelation{
MemoID: &mainMemo.ID,
})
require.NoError(t, err)
require.Equal(t, 0, len(relations))
ts.Close()
}
func TestMemoRelationDifferentTypes(t *testing.T) {
ctx := context.Background()
ts := NewTestingStore(ctx, t)
user, err := createTestingHostUser(ctx, ts)
require.NoError(t, err)
mainMemo, err := ts.CreateMemo(ctx, &store.Memo{
UID: "main-memo",
CreatorID: user.ID,
Content: "main memo content",
Visibility: store.Public,
})
require.NoError(t, err)
relatedMemo, err := ts.CreateMemo(ctx, &store.Memo{
UID: "related-memo",
CreatorID: user.ID,
Content: "related memo content",
Visibility: store.Public,
})
require.NoError(t, err)
// Create reference relation
_, err = ts.UpsertMemoRelation(ctx, &store.MemoRelation{
MemoID: mainMemo.ID,
RelatedMemoID: relatedMemo.ID,
Type: store.MemoRelationReference,
})
require.NoError(t, err)
// Create comment relation (same memos, different type - should be allowed)
_, err = ts.UpsertMemoRelation(ctx, &store.MemoRelation{
MemoID: mainMemo.ID,
RelatedMemoID: relatedMemo.ID,
Type: store.MemoRelationComment,
})
require.NoError(t, err)
// Verify both relations exist
relations, err := ts.ListMemoRelations(ctx, &store.FindMemoRelation{
MemoID: &mainMemo.ID,
})
require.NoError(t, err)
require.Equal(t, 2, len(relations))
ts.Close()
}

View File

@ -2,6 +2,7 @@ package test
import (
"context"
"fmt"
"testing"
"github.com/stretchr/testify/require"
@ -114,3 +115,294 @@ func TestDeleteMemoStore(t *testing.T) {
require.NoError(t, err)
ts.Close()
}
func TestMemoGetByID(t *testing.T) {
ctx := context.Background()
ts := NewTestingStore(ctx, t)
user, err := createTestingHostUser(ctx, ts)
require.NoError(t, err)
memo, err := ts.CreateMemo(ctx, &store.Memo{
UID: "test-memo-1",
CreatorID: user.ID,
Content: "test content",
Visibility: store.Public,
})
require.NoError(t, err)
// Get by ID
found, err := ts.GetMemo(ctx, &store.FindMemo{ID: &memo.ID})
require.NoError(t, err)
require.NotNil(t, found)
require.Equal(t, memo.ID, found.ID)
require.Equal(t, memo.Content, found.Content)
// Get non-existent
nonExistentID := int32(99999)
notFound, err := ts.GetMemo(ctx, &store.FindMemo{ID: &nonExistentID})
require.NoError(t, err)
require.Nil(t, notFound)
ts.Close()
}
func TestMemoGetByUID(t *testing.T) {
ctx := context.Background()
ts := NewTestingStore(ctx, t)
user, err := createTestingHostUser(ctx, ts)
require.NoError(t, err)
uid := "unique-memo-uid"
memo, err := ts.CreateMemo(ctx, &store.Memo{
UID: uid,
CreatorID: user.ID,
Content: "test content",
Visibility: store.Public,
})
require.NoError(t, err)
// Get by UID
found, err := ts.GetMemo(ctx, &store.FindMemo{UID: &uid})
require.NoError(t, err)
require.NotNil(t, found)
require.Equal(t, memo.UID, found.UID)
// Get non-existent UID
nonExistentUID := "non-existent-uid"
notFound, err := ts.GetMemo(ctx, &store.FindMemo{UID: &nonExistentUID})
require.NoError(t, err)
require.Nil(t, notFound)
ts.Close()
}
func TestMemoListByVisibility(t *testing.T) {
ctx := context.Background()
ts := NewTestingStore(ctx, t)
user, err := createTestingHostUser(ctx, ts)
require.NoError(t, err)
// Create memos with different visibilities
_, err = ts.CreateMemo(ctx, &store.Memo{
UID: "public-memo",
CreatorID: user.ID,
Content: "public content",
Visibility: store.Public,
})
require.NoError(t, err)
_, err = ts.CreateMemo(ctx, &store.Memo{
UID: "protected-memo",
CreatorID: user.ID,
Content: "protected content",
Visibility: store.Protected,
})
require.NoError(t, err)
_, err = ts.CreateMemo(ctx, &store.Memo{
UID: "private-memo",
CreatorID: user.ID,
Content: "private content",
Visibility: store.Private,
})
require.NoError(t, err)
// List public memos only
publicMemos, err := ts.ListMemos(ctx, &store.FindMemo{
VisibilityList: []store.Visibility{store.Public},
})
require.NoError(t, err)
require.Equal(t, 1, len(publicMemos))
require.Equal(t, store.Public, publicMemos[0].Visibility)
// List protected memos only
protectedMemos, err := ts.ListMemos(ctx, &store.FindMemo{
VisibilityList: []store.Visibility{store.Protected},
})
require.NoError(t, err)
require.Equal(t, 1, len(protectedMemos))
require.Equal(t, store.Protected, protectedMemos[0].Visibility)
// List public and protected (multiple visibility)
publicAndProtected, err := ts.ListMemos(ctx, &store.FindMemo{
VisibilityList: []store.Visibility{store.Public, store.Protected},
})
require.NoError(t, err)
require.Equal(t, 2, len(publicAndProtected))
// List all
allMemos, err := ts.ListMemos(ctx, &store.FindMemo{})
require.NoError(t, err)
require.Equal(t, 3, len(allMemos))
ts.Close()
}
func TestMemoListWithPagination(t *testing.T) {
ctx := context.Background()
ts := NewTestingStore(ctx, t)
user, err := createTestingHostUser(ctx, ts)
require.NoError(t, err)
// Create 10 memos
for i := 0; i < 10; i++ {
_, err := ts.CreateMemo(ctx, &store.Memo{
UID: fmt.Sprintf("memo-%d", i),
CreatorID: user.ID,
Content: fmt.Sprintf("content %d", i),
Visibility: store.Public,
})
require.NoError(t, err)
}
// Test limit
limit := 5
limitedMemos, err := ts.ListMemos(ctx, &store.FindMemo{Limit: &limit})
require.NoError(t, err)
require.Equal(t, 5, len(limitedMemos))
// Test offset
offset := 3
offsetMemos, err := ts.ListMemos(ctx, &store.FindMemo{Limit: &limit, Offset: &offset})
require.NoError(t, err)
require.Equal(t, 5, len(offsetMemos))
// Verify offset works correctly (different memos)
require.NotEqual(t, limitedMemos[0].ID, offsetMemos[0].ID)
ts.Close()
}
func TestMemoUpdatePinned(t *testing.T) {
ctx := context.Background()
ts := NewTestingStore(ctx, t)
user, err := createTestingHostUser(ctx, ts)
require.NoError(t, err)
memo, err := ts.CreateMemo(ctx, &store.Memo{
UID: "pinnable-memo",
CreatorID: user.ID,
Content: "content",
Visibility: store.Public,
})
require.NoError(t, err)
require.False(t, memo.Pinned)
// Pin the memo
pinned := true
err = ts.UpdateMemo(ctx, &store.UpdateMemo{
ID: memo.ID,
Pinned: &pinned,
})
require.NoError(t, err)
// Verify pinned
found, err := ts.GetMemo(ctx, &store.FindMemo{ID: &memo.ID})
require.NoError(t, err)
require.True(t, found.Pinned)
// Unpin
unpinned := false
err = ts.UpdateMemo(ctx, &store.UpdateMemo{
ID: memo.ID,
Pinned: &unpinned,
})
require.NoError(t, err)
found, err = ts.GetMemo(ctx, &store.FindMemo{ID: &memo.ID})
require.NoError(t, err)
require.False(t, found.Pinned)
ts.Close()
}
func TestMemoUpdateVisibility(t *testing.T) {
ctx := context.Background()
ts := NewTestingStore(ctx, t)
user, err := createTestingHostUser(ctx, ts)
require.NoError(t, err)
memo, err := ts.CreateMemo(ctx, &store.Memo{
UID: "visibility-memo",
CreatorID: user.ID,
Content: "content",
Visibility: store.Public,
})
require.NoError(t, err)
require.Equal(t, store.Public, memo.Visibility)
// Change to private
privateVisibility := store.Private
err = ts.UpdateMemo(ctx, &store.UpdateMemo{
ID: memo.ID,
Visibility: &privateVisibility,
})
require.NoError(t, err)
found, err := ts.GetMemo(ctx, &store.FindMemo{ID: &memo.ID})
require.NoError(t, err)
require.Equal(t, store.Private, found.Visibility)
// Change to protected
protectedVisibility := store.Protected
err = ts.UpdateMemo(ctx, &store.UpdateMemo{
ID: memo.ID,
Visibility: &protectedVisibility,
})
require.NoError(t, err)
found, err = ts.GetMemo(ctx, &store.FindMemo{ID: &memo.ID})
require.NoError(t, err)
require.Equal(t, store.Protected, found.Visibility)
ts.Close()
}
func TestMemoInvalidUID(t *testing.T) {
ctx := context.Background()
ts := NewTestingStore(ctx, t)
user, err := createTestingHostUser(ctx, ts)
require.NoError(t, err)
// Create memo with invalid UID (contains special characters)
_, err = ts.CreateMemo(ctx, &store.Memo{
UID: "invalid uid with spaces",
CreatorID: user.ID,
Content: "content",
Visibility: store.Public,
})
require.Error(t, err)
require.Contains(t, err.Error(), "invalid uid")
ts.Close()
}
func TestMemoWithPayload(t *testing.T) {
ctx := context.Background()
ts := NewTestingStore(ctx, t)
user, err := createTestingHostUser(ctx, ts)
require.NoError(t, err)
// Create memo with tags in payload
tags := []string{"tag1", "tag2", "tag3"}
memo, err := ts.CreateMemo(ctx, &store.Memo{
UID: "memo-with-payload",
CreatorID: user.ID,
Content: "content with tags",
Visibility: store.Public,
Payload: &storepb.MemoPayload{
Tags: tags,
},
})
require.NoError(t, err)
require.NotNil(t, memo.Payload)
require.Equal(t, tags, memo.Payload.Tags)
// Fetch and verify
found, err := ts.GetMemo(ctx, &store.FindMemo{ID: &memo.ID})
require.NoError(t, err)
require.NotNil(t, found.Payload)
require.Equal(t, tags, found.Payload.Tags)
ts.Close()
}

View File

@ -65,3 +65,121 @@ func TestReactionStore(t *testing.T) {
ts.Close()
}
func TestReactionListByCreatorID(t *testing.T) {
ctx := context.Background()
ts := NewTestingStore(ctx, t)
user1, err := createTestingHostUser(ctx, ts)
require.NoError(t, err)
user2, err := createTestingUserWithRole(ctx, ts, "user2", store.RoleUser)
require.NoError(t, err)
contentID := "shared_content"
// User 1 creates reaction
_, err = ts.UpsertReaction(ctx, &store.Reaction{
CreatorID: user1.ID,
ContentID: contentID,
ReactionType: "👍",
})
require.NoError(t, err)
// User 2 creates reaction
_, err = ts.UpsertReaction(ctx, &store.Reaction{
CreatorID: user2.ID,
ContentID: contentID,
ReactionType: "❤️",
})
require.NoError(t, err)
// List all reactions for content
reactions, err := ts.ListReactions(ctx, &store.FindReaction{
ContentID: &contentID,
})
require.NoError(t, err)
require.Len(t, reactions, 2)
// List by creator ID
user1Reactions, err := ts.ListReactions(ctx, &store.FindReaction{
CreatorID: &user1.ID,
})
require.NoError(t, err)
require.Len(t, user1Reactions, 1)
require.Equal(t, "👍", user1Reactions[0].ReactionType)
ts.Close()
}
func TestReactionMultipleContentIDs(t *testing.T) {
ctx := context.Background()
ts := NewTestingStore(ctx, t)
user, err := createTestingHostUser(ctx, ts)
require.NoError(t, err)
contentID1 := "content_1"
contentID2 := "content_2"
// Create reactions for different contents
_, err = ts.UpsertReaction(ctx, &store.Reaction{
CreatorID: user.ID,
ContentID: contentID1,
ReactionType: "👍",
})
require.NoError(t, err)
_, err = ts.UpsertReaction(ctx, &store.Reaction{
CreatorID: user.ID,
ContentID: contentID2,
ReactionType: "❤️",
})
require.NoError(t, err)
// List by content ID list
reactions, err := ts.ListReactions(ctx, &store.FindReaction{
ContentIDList: []string{contentID1, contentID2},
})
require.NoError(t, err)
require.Len(t, reactions, 2)
ts.Close()
}
func TestReactionUpsertDifferentTypes(t *testing.T) {
ctx := context.Background()
ts := NewTestingStore(ctx, t)
user, err := createTestingHostUser(ctx, ts)
require.NoError(t, err)
contentID := "test_content"
// Create first reaction
reaction1, err := ts.UpsertReaction(ctx, &store.Reaction{
CreatorID: user.ID,
ContentID: contentID,
ReactionType: "👍",
})
require.NoError(t, err)
// Create second reaction with different type (should create new, not update)
reaction2, err := ts.UpsertReaction(ctx, &store.Reaction{
CreatorID: user.ID,
ContentID: contentID,
ReactionType: "❤️",
})
require.NoError(t, err)
// Both reactions should exist
require.NotEqual(t, reaction1.ID, reaction2.ID)
reactions, err := ts.ListReactions(ctx, &store.FindReaction{
ContentID: &contentID,
})
require.NoError(t, err)
require.Len(t, reactions, 2)
ts.Close()
}

View File

@ -3,7 +3,6 @@ package test
import (
"context"
"fmt"
"log/slog"
"net"
"os"
"testing"
@ -19,63 +18,25 @@ import (
"github.com/usememos/memos/store/db"
)
// NewTestingStore creates a new testing store with a fresh database.
// Each test gets its own isolated database:
// - SQLite: new temp file per test
// - MySQL/PostgreSQL: new database per test in shared container
func NewTestingStore(ctx context.Context, t *testing.T) *store.Store {
profile := getTestingProfile(t)
driver := getDriverFromEnv()
profile := getTestingProfileForDriver(t, driver)
dbDriver, err := db.NewDBDriver(profile)
if err != nil {
slog.Error("failed to create db driver", slog.String("error", err.Error()))
t.Fatalf("failed to create db driver: %v", err)
}
resetTestingDB(ctx, profile, dbDriver)
store := store.New(dbDriver, profile)
if err := store.Migrate(ctx); err != nil {
slog.Error("failed to migrate db", slog.String("error", err.Error()))
t.Fatalf("failed to migrate db: %v", err)
}
return store
}
func resetTestingDB(ctx context.Context, profile *profile.Profile, dbDriver store.Driver) {
if profile.Driver == "mysql" {
_, err := dbDriver.GetDB().ExecContext(ctx, `
DROP TABLE IF EXISTS system_setting;
DROP TABLE IF EXISTS user;
DROP TABLE IF EXISTS user_setting;
DROP TABLE IF EXISTS memo;
DROP TABLE IF EXISTS memo_organizer;
DROP TABLE IF EXISTS memo_relation;
DROP TABLE IF EXISTS resource;
DROP TABLE IF EXISTS tag;
DROP TABLE IF EXISTS activity;
DROP TABLE IF EXISTS storage;
DROP TABLE IF EXISTS idp;
DROP TABLE IF EXISTS inbox;
DROP TABLE IF EXISTS reaction;`)
if err != nil {
slog.Error("failed to reset testing db", slog.String("error", err.Error()))
panic(err)
}
} else if profile.Driver == "postgres" {
_, err := dbDriver.GetDB().ExecContext(ctx, `
DROP TABLE IF EXISTS system_setting CASCADE;
DROP TABLE IF EXISTS "user" CASCADE;
DROP TABLE IF EXISTS user_setting CASCADE;
DROP TABLE IF EXISTS memo CASCADE;
DROP TABLE IF EXISTS memo_organizer CASCADE;
DROP TABLE IF EXISTS memo_relation CASCADE;
DROP TABLE IF EXISTS resource CASCADE;
DROP TABLE IF EXISTS tag CASCADE;
DROP TABLE IF EXISTS activity CASCADE;
DROP TABLE IF EXISTS storage CASCADE;
DROP TABLE IF EXISTS idp CASCADE;
DROP TABLE IF EXISTS inbox CASCADE;
DROP TABLE IF EXISTS reaction CASCADE;`)
if err != nil {
slog.Error("failed to reset testing db", slog.String("error", err.Error()))
panic(err)
}
}
}
func getUnusedPort() int {
// Get a random unused port
listener, err := net.Listen("tcp", "localhost:0")
@ -89,20 +50,28 @@ func getUnusedPort() int {
return port
}
func getTestingProfile(t *testing.T) *profile.Profile {
if err := godotenv.Load(".env"); err != nil {
t.Log("failed to load .env file, but it's ok")
}
// getTestingProfileForDriver creates a testing profile for a specific driver.
func getTestingProfileForDriver(t *testing.T, driver string) *profile.Profile {
// Attempt to load .env file if present (optional, for local development)
_ = godotenv.Load(".env")
// Get a temporary directory for the test data.
dir := t.TempDir()
mode := "prod"
port := getUnusedPort()
driver := getDriverFromEnv()
dsn := os.Getenv("DSN")
if driver == "sqlite" {
var dsn string
switch driver {
case "sqlite":
dsn = fmt.Sprintf("%s/memos_%s.db", dir, mode)
case "mysql":
dsn = GetMySQLDSN(t)
case "postgres":
dsn = GetPostgresDSN(t)
default:
t.Fatalf("unsupported driver: %s", driver)
}
return &profile.Profile{
Mode: mode,
Port: port,

View File

@ -26,3 +26,285 @@ func TestUserSettingStore(t *testing.T) {
require.Equal(t, 1, len(list))
ts.Close()
}
func TestUserSettingGetByUserID(t *testing.T) {
ctx := context.Background()
ts := NewTestingStore(ctx, t)
user, err := createTestingHostUser(ctx, ts)
require.NoError(t, err)
// Create setting
_, err = ts.UpsertUserSetting(ctx, &storepb.UserSetting{
UserId: user.ID,
Key: storepb.UserSetting_GENERAL,
Value: &storepb.UserSetting_General{General: &storepb.GeneralUserSetting{Locale: "zh"}},
})
require.NoError(t, err)
// Get by user ID
setting, err := ts.GetUserSetting(ctx, &store.FindUserSetting{
UserID: &user.ID,
Key: storepb.UserSetting_GENERAL,
})
require.NoError(t, err)
require.NotNil(t, setting)
require.Equal(t, "zh", setting.GetGeneral().Locale)
// Get non-existent key
nonExistentSetting, err := ts.GetUserSetting(ctx, &store.FindUserSetting{
UserID: &user.ID,
Key: storepb.UserSetting_SHORTCUTS,
})
require.NoError(t, err)
require.Nil(t, nonExistentSetting)
ts.Close()
}
func TestUserSettingUpsertUpdate(t *testing.T) {
ctx := context.Background()
ts := NewTestingStore(ctx, t)
user, err := createTestingHostUser(ctx, ts)
require.NoError(t, err)
// Create initial setting
_, err = ts.UpsertUserSetting(ctx, &storepb.UserSetting{
UserId: user.ID,
Key: storepb.UserSetting_GENERAL,
Value: &storepb.UserSetting_General{General: &storepb.GeneralUserSetting{Locale: "en"}},
})
require.NoError(t, err)
// Update setting
_, err = ts.UpsertUserSetting(ctx, &storepb.UserSetting{
UserId: user.ID,
Key: storepb.UserSetting_GENERAL,
Value: &storepb.UserSetting_General{General: &storepb.GeneralUserSetting{Locale: "fr"}},
})
require.NoError(t, err)
// Verify update
setting, err := ts.GetUserSetting(ctx, &store.FindUserSetting{
UserID: &user.ID,
Key: storepb.UserSetting_GENERAL,
})
require.NoError(t, err)
require.Equal(t, "fr", setting.GetGeneral().Locale)
// Verify only one setting exists
list, err := ts.ListUserSettings(ctx, &store.FindUserSetting{UserID: &user.ID})
require.NoError(t, err)
require.Equal(t, 1, len(list))
ts.Close()
}
func TestUserSettingRefreshTokens(t *testing.T) {
ctx := context.Background()
ts := NewTestingStore(ctx, t)
user, err := createTestingHostUser(ctx, ts)
require.NoError(t, err)
// Initially no tokens
tokens, err := ts.GetUserRefreshTokens(ctx, user.ID)
require.NoError(t, err)
require.Empty(t, tokens)
// Add a refresh token
token1 := &storepb.RefreshTokensUserSetting_RefreshToken{
TokenId: "token-1",
Description: "Chrome browser session",
}
err = ts.AddUserRefreshToken(ctx, user.ID, token1)
require.NoError(t, err)
// Verify token was added
tokens, err = ts.GetUserRefreshTokens(ctx, user.ID)
require.NoError(t, err)
require.Len(t, tokens, 1)
require.Equal(t, "token-1", tokens[0].TokenId)
// Add another token
token2 := &storepb.RefreshTokensUserSetting_RefreshToken{
TokenId: "token-2",
Description: "Firefox browser session",
}
err = ts.AddUserRefreshToken(ctx, user.ID, token2)
require.NoError(t, err)
tokens, err = ts.GetUserRefreshTokens(ctx, user.ID)
require.NoError(t, err)
require.Len(t, tokens, 2)
// Get specific token by ID
foundToken, err := ts.GetUserRefreshTokenByID(ctx, user.ID, "token-1")
require.NoError(t, err)
require.NotNil(t, foundToken)
require.Equal(t, "Chrome browser session", foundToken.Description)
// Get non-existent token
notFound, err := ts.GetUserRefreshTokenByID(ctx, user.ID, "non-existent")
require.NoError(t, err)
require.Nil(t, notFound)
// Remove token
err = ts.RemoveUserRefreshToken(ctx, user.ID, "token-1")
require.NoError(t, err)
tokens, err = ts.GetUserRefreshTokens(ctx, user.ID)
require.NoError(t, err)
require.Len(t, tokens, 1)
require.Equal(t, "token-2", tokens[0].TokenId)
ts.Close()
}
func TestUserSettingPersonalAccessTokens(t *testing.T) {
ctx := context.Background()
ts := NewTestingStore(ctx, t)
user, err := createTestingHostUser(ctx, ts)
require.NoError(t, err)
// Initially no PATs
pats, err := ts.GetUserPersonalAccessTokens(ctx, user.ID)
require.NoError(t, err)
require.Empty(t, pats)
// Add a PAT
pat1 := &storepb.PersonalAccessTokensUserSetting_PersonalAccessToken{
TokenId: "pat-1",
TokenHash: "pat-hash-1",
Description: "API Token for external access",
}
err = ts.AddUserPersonalAccessToken(ctx, user.ID, pat1)
require.NoError(t, err)
// Verify PAT was added
pats, err = ts.GetUserPersonalAccessTokens(ctx, user.ID)
require.NoError(t, err)
require.Len(t, pats, 1)
require.Equal(t, "API Token for external access", pats[0].Description)
// Add another PAT
pat2 := &storepb.PersonalAccessTokensUserSetting_PersonalAccessToken{
TokenId: "pat-2",
TokenHash: "pat-hash-2",
Description: "CI Token",
}
err = ts.AddUserPersonalAccessToken(ctx, user.ID, pat2)
require.NoError(t, err)
pats, err = ts.GetUserPersonalAccessTokens(ctx, user.ID)
require.NoError(t, err)
require.Len(t, pats, 2)
// Remove PAT
err = ts.RemoveUserPersonalAccessToken(ctx, user.ID, "pat-1")
require.NoError(t, err)
pats, err = ts.GetUserPersonalAccessTokens(ctx, user.ID)
require.NoError(t, err)
require.Len(t, pats, 1)
require.Equal(t, "pat-2", pats[0].TokenId)
ts.Close()
}
func TestUserSettingWebhooks(t *testing.T) {
ctx := context.Background()
ts := NewTestingStore(ctx, t)
user, err := createTestingHostUser(ctx, ts)
require.NoError(t, err)
// Initially no webhooks
webhooks, err := ts.GetUserWebhooks(ctx, user.ID)
require.NoError(t, err)
require.Empty(t, webhooks)
// Add a webhook
webhook1 := &storepb.WebhooksUserSetting_Webhook{
Id: "webhook-1",
Title: "Deploy Hook",
Url: "https://example.com/webhook",
}
err = ts.AddUserWebhook(ctx, user.ID, webhook1)
require.NoError(t, err)
// Verify webhook was added
webhooks, err = ts.GetUserWebhooks(ctx, user.ID)
require.NoError(t, err)
require.Len(t, webhooks, 1)
require.Equal(t, "Deploy Hook", webhooks[0].Title)
// Update webhook
webhook1Updated := &storepb.WebhooksUserSetting_Webhook{
Id: "webhook-1",
Title: "Updated Deploy Hook",
Url: "https://example.com/webhook/v2",
}
err = ts.UpdateUserWebhook(ctx, user.ID, webhook1Updated)
require.NoError(t, err)
webhooks, err = ts.GetUserWebhooks(ctx, user.ID)
require.NoError(t, err)
require.Len(t, webhooks, 1)
require.Equal(t, "Updated Deploy Hook", webhooks[0].Title)
require.Equal(t, "https://example.com/webhook/v2", webhooks[0].Url)
// Add another webhook
webhook2 := &storepb.WebhooksUserSetting_Webhook{
Id: "webhook-2",
Title: "Notification Hook",
Url: "https://slack.example.com/webhook",
}
err = ts.AddUserWebhook(ctx, user.ID, webhook2)
require.NoError(t, err)
webhooks, err = ts.GetUserWebhooks(ctx, user.ID)
require.NoError(t, err)
require.Len(t, webhooks, 2)
// Remove webhook
err = ts.RemoveUserWebhook(ctx, user.ID, "webhook-1")
require.NoError(t, err)
webhooks, err = ts.GetUserWebhooks(ctx, user.ID)
require.NoError(t, err)
require.Len(t, webhooks, 1)
require.Equal(t, "webhook-2", webhooks[0].Id)
ts.Close()
}
func TestUserSettingShortcuts(t *testing.T) {
ctx := context.Background()
ts := NewTestingStore(ctx, t)
user, err := createTestingHostUser(ctx, ts)
require.NoError(t, err)
// Create shortcuts setting
shortcuts := &storepb.ShortcutsUserSetting{
Shortcuts: []*storepb.ShortcutsUserSetting_Shortcut{
{Id: "shortcut-1", Title: "Work Notes", Filter: "tag:work"},
{Id: "shortcut-2", Title: "Personal", Filter: "tag:personal"},
},
}
_, err = ts.UpsertUserSetting(ctx, &storepb.UserSetting{
UserId: user.ID,
Key: storepb.UserSetting_SHORTCUTS,
Value: &storepb.UserSetting_Shortcuts{Shortcuts: shortcuts},
})
require.NoError(t, err)
// Retrieve and verify
setting, err := ts.GetUserSetting(ctx, &store.FindUserSetting{
UserID: &user.ID,
Key: storepb.UserSetting_SHORTCUTS,
})
require.NoError(t, err)
require.NotNil(t, setting)
require.Len(t, setting.GetShortcuts().Shortcuts, 2)
require.Equal(t, "Work Notes", setting.GetShortcuts().Shortcuts[0].Title)
ts.Close()
}

View File

@ -2,6 +2,7 @@ package test
import (
"context"
"fmt"
"testing"
"github.com/stretchr/testify/require"
@ -38,13 +39,213 @@ func TestUserStore(t *testing.T) {
ts.Close()
}
func TestUserGetByID(t *testing.T) {
ctx := context.Background()
ts := NewTestingStore(ctx, t)
user, err := createTestingHostUser(ctx, ts)
require.NoError(t, err)
// Get user by ID
found, err := ts.GetUser(ctx, &store.FindUser{ID: &user.ID})
require.NoError(t, err)
require.NotNil(t, found)
require.Equal(t, user.ID, found.ID)
require.Equal(t, user.Username, found.Username)
// Get non-existent user
nonExistentID := int32(99999)
notFound, err := ts.GetUser(ctx, &store.FindUser{ID: &nonExistentID})
require.NoError(t, err)
require.Nil(t, notFound)
// Get system bot
systemBotID := store.SystemBotID
systemBot, err := ts.GetUser(ctx, &store.FindUser{ID: &systemBotID})
require.NoError(t, err)
require.NotNil(t, systemBot)
require.Equal(t, store.SystemBotID, systemBot.ID)
require.Equal(t, "system_bot", systemBot.Username)
ts.Close()
}
func TestUserGetByUsername(t *testing.T) {
ctx := context.Background()
ts := NewTestingStore(ctx, t)
user, err := createTestingHostUser(ctx, ts)
require.NoError(t, err)
// Get user by username
found, err := ts.GetUser(ctx, &store.FindUser{Username: &user.Username})
require.NoError(t, err)
require.NotNil(t, found)
require.Equal(t, user.Username, found.Username)
// Get non-existent username
nonExistent := "nonexistent"
notFound, err := ts.GetUser(ctx, &store.FindUser{Username: &nonExistent})
require.NoError(t, err)
require.Nil(t, notFound)
ts.Close()
}
func TestUserListByRole(t *testing.T) {
ctx := context.Background()
ts := NewTestingStore(ctx, t)
// Create users with different roles
_, err := createTestingHostUser(ctx, ts)
require.NoError(t, err)
adminUser, err := createTestingUserWithRole(ctx, ts, "admin_user", store.RoleAdmin)
require.NoError(t, err)
regularUser, err := createTestingUserWithRole(ctx, ts, "regular_user", store.RoleUser)
require.NoError(t, err)
// List all users
allUsers, err := ts.ListUsers(ctx, &store.FindUser{})
require.NoError(t, err)
require.Equal(t, 3, len(allUsers))
// List only HOST users
hostRole := store.RoleHost
hostUsers, err := ts.ListUsers(ctx, &store.FindUser{Role: &hostRole})
require.NoError(t, err)
require.Equal(t, 1, len(hostUsers))
require.Equal(t, store.RoleHost, hostUsers[0].Role)
// List only ADMIN users
adminRole := store.RoleAdmin
adminUsers, err := ts.ListUsers(ctx, &store.FindUser{Role: &adminRole})
require.NoError(t, err)
require.Equal(t, 1, len(adminUsers))
require.Equal(t, adminUser.ID, adminUsers[0].ID)
// List only USER role users
userRole := store.RoleUser
regularUsers, err := ts.ListUsers(ctx, &store.FindUser{Role: &userRole})
require.NoError(t, err)
require.Equal(t, 1, len(regularUsers))
require.Equal(t, regularUser.ID, regularUsers[0].ID)
ts.Close()
}
func TestUserUpdateRowStatus(t *testing.T) {
ctx := context.Background()
ts := NewTestingStore(ctx, t)
user, err := createTestingHostUser(ctx, ts)
require.NoError(t, err)
require.Equal(t, store.Normal, user.RowStatus)
// Archive user
archivedStatus := store.Archived
updated, err := ts.UpdateUser(ctx, &store.UpdateUser{
ID: user.ID,
RowStatus: &archivedStatus,
})
require.NoError(t, err)
require.Equal(t, store.Archived, updated.RowStatus)
// Verify by fetching
fetched, err := ts.GetUser(ctx, &store.FindUser{ID: &user.ID})
require.NoError(t, err)
require.Equal(t, store.Archived, fetched.RowStatus)
// Restore to normal
normalStatus := store.Normal
restored, err := ts.UpdateUser(ctx, &store.UpdateUser{
ID: user.ID,
RowStatus: &normalStatus,
})
require.NoError(t, err)
require.Equal(t, store.Normal, restored.RowStatus)
ts.Close()
}
func TestUserUpdateAllFields(t *testing.T) {
ctx := context.Background()
ts := NewTestingStore(ctx, t)
user, err := createTestingHostUser(ctx, ts)
require.NoError(t, err)
// Update all fields
newUsername := "updated_username"
newEmail := "updated@test.com"
newNickname := "Updated Nickname"
newAvatarURL := "https://example.com/avatar.png"
newDescription := "Updated description"
newRole := store.RoleAdmin
newPasswordHash := "new_password_hash"
updated, err := ts.UpdateUser(ctx, &store.UpdateUser{
ID: user.ID,
Username: &newUsername,
Email: &newEmail,
Nickname: &newNickname,
AvatarURL: &newAvatarURL,
Description: &newDescription,
Role: &newRole,
PasswordHash: &newPasswordHash,
})
require.NoError(t, err)
require.Equal(t, newUsername, updated.Username)
require.Equal(t, newEmail, updated.Email)
require.Equal(t, newNickname, updated.Nickname)
require.Equal(t, newAvatarURL, updated.AvatarURL)
require.Equal(t, newDescription, updated.Description)
require.Equal(t, newRole, updated.Role)
require.Equal(t, newPasswordHash, updated.PasswordHash)
// Verify by fetching again
fetched, err := ts.GetUser(ctx, &store.FindUser{ID: &user.ID})
require.NoError(t, err)
require.Equal(t, newUsername, fetched.Username)
ts.Close()
}
func TestUserListWithLimit(t *testing.T) {
ctx := context.Background()
ts := NewTestingStore(ctx, t)
// Create 5 users
for i := 0; i < 5; i++ {
role := store.RoleUser
if i == 0 {
role = store.RoleHost
}
_, err := createTestingUserWithRole(ctx, ts, fmt.Sprintf("user%d", i), role)
require.NoError(t, err)
}
// List with limit
limit := 3
users, err := ts.ListUsers(ctx, &store.FindUser{Limit: &limit})
require.NoError(t, err)
require.Equal(t, 3, len(users))
ts.Close()
}
func createTestingHostUser(ctx context.Context, ts *store.Store) (*store.User, error) {
return createTestingUserWithRole(ctx, ts, "test", store.RoleHost)
}
func createTestingUserWithRole(ctx context.Context, ts *store.Store, username string, role store.Role) (*store.User, error) {
userCreate := &store.User{
Username: "test",
Role: store.RoleHost,
Email: "test@test.com",
Nickname: "test_nickname",
Description: "test_description",
Username: username,
Role: role,
Email: username + "@test.com",
Nickname: username + "_nickname",
Description: username + "_description",
}
passwordHash, err := bcrypt.GenerateFromPassword([]byte("test_password"), bcrypt.DefaultCost)
if err != nil {

View File

@ -34,7 +34,7 @@ const LocationMarker = (props: MarkerProps) => {
// Call the parent onChange function.
props.onChange(e.latlng);
},
locationfound() { },
locationfound() {},
});
useEffect(() => {
@ -246,7 +246,7 @@ const LeafletMap = (props: MapProps) => {
isDark ? "https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png" : "https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
}
/>
<LocationMarker position={position} readonly={props.readonly} onChange={props.onChange ? props.onChange : () => { }} />
<LocationMarker position={position} readonly={props.readonly} onChange={props.onChange ? props.onChange : () => {}} />
<MapControls position={props.latlng} />
<MapCleanup />
</MapContainer>