Compare commits
895
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
06456afd49 | ||
|
|
d98e70922d | ||
|
|
94a5fd3617 | ||
|
|
5bb1346da8 | ||
|
|
a93eecaeee | ||
|
|
86131d4bd8 | ||
|
|
581b62cf01 | ||
|
|
91714ee413 | ||
|
|
232149e63f | ||
|
|
4a1ddea431 | ||
|
|
5539bf8788 | ||
|
|
dda7eb03c9 | ||
|
|
c2f2be6b08 | ||
|
|
b7649f61f8 | ||
|
|
ae4a9040df | ||
|
|
d2b63df7a1 | ||
|
|
0749e6e090 | ||
|
|
4589157963 | ||
|
|
37d4dbeb96 | ||
|
|
c036a12999 | ||
|
|
47cd94ec3e | ||
|
|
e5cf0d0bf6 | ||
|
|
60c2e209b6 | ||
|
|
6e743a495f | ||
|
|
2ea07cd8f8 | ||
|
|
2ea8e11882 | ||
|
|
ff3e3338cb | ||
|
|
eb0e63c336 | ||
|
|
16dc744908 | ||
|
|
f3258bab9e | ||
|
|
472f8a8a2b | ||
|
|
c8807d3f89 | ||
|
|
5e6053b367 | ||
|
|
f5e36a8a2b | ||
|
|
660efda7f5 | ||
|
|
5e96e4dda6 | ||
|
|
a31eea8389 | ||
|
|
6aa0c9e5cc | ||
|
|
8fe47a3c04 | ||
|
|
cb4670cd68 | ||
|
|
1400b9c6e2 | ||
|
|
b7d9483bc2 | ||
|
|
9df9db3a8f | ||
|
|
bf4f9b04bf | ||
|
|
a659cbf71d | ||
|
|
72514904ea | ||
|
|
f3fbd4f90e | ||
|
|
5947cc2bab | ||
|
|
31109735a3 | ||
|
|
df025ac400 | ||
|
|
f4a3e290cb | ||
|
|
66e85ca16c | ||
|
|
ac8136cca4 | ||
|
|
8c111de6a9 | ||
|
|
d784a0432c | ||
|
|
1636b228db | ||
|
|
e36d460d67 | ||
|
|
68f195c5e9 | ||
|
|
200e801182 | ||
|
|
5bcf5be874 | ||
|
|
b60ba0b192 | ||
|
|
26dc4d43bf | ||
|
|
0e355079fa | ||
|
|
b51bf33b1e | ||
|
|
30594978f9 | ||
|
|
2e2d7a5345 | ||
|
|
cf2457a03c | ||
|
|
4590dab046 | ||
|
|
f2df8af4c8 | ||
|
|
5b93ed3fcd | ||
|
|
5fd83e9425 | ||
|
|
8f6937eea6 | ||
|
|
74fc026fe0 | ||
|
|
bbd466eaba | ||
|
|
153609b968 | ||
|
|
6cd7ab4719 | ||
|
|
39de9e7248 | ||
|
|
5e93a61865 | ||
|
|
76069b5d6d | ||
|
|
adf673d031 | ||
|
|
2fa6684c4d | ||
|
|
87439b8fec | ||
|
|
08034532f6 | ||
|
|
c5f47ebccd | ||
|
|
6744306818 | ||
|
|
3ff14ccc89 | ||
|
|
9c30f16e4b | ||
|
|
c31933c163 | ||
|
|
574eb1a10a | ||
|
|
e6ac783fc3 | ||
|
|
689a75f44a | ||
|
|
a25f469bde | ||
|
|
044ee7ef54 | ||
|
|
2725f28fa8 | ||
|
|
c493e08df1 | ||
|
|
36ad784251 | ||
|
|
0a39e5c092 | ||
|
|
9169a5e35b | ||
|
|
bfdcb79e19 | ||
|
|
c37cce000f | ||
|
|
6d3fb9b769 | ||
|
|
c04913ecf8 | ||
|
|
3a84a64c32 | ||
|
|
b344d4add1 | ||
|
|
405a4ec9f8 | ||
|
|
8bb11a588c | ||
|
|
44f451bd7d | ||
|
|
35d914e755 | ||
|
|
9c37f8c1cb | ||
|
|
9de0e3c3a7 | ||
|
|
707c94f86e | ||
|
|
81afd087f6 | ||
|
|
0f952f328f | ||
|
|
50619fba0a | ||
|
|
aad31bb703 | ||
|
|
7484a020e1 | ||
|
|
186828c13a | ||
|
|
203fb95391 | ||
|
|
a94e650ffd | ||
|
|
00fdd06179 | ||
|
|
62fbc90389 | ||
|
|
f19a21da11 | ||
|
|
7401792063 | ||
|
|
79e46c8a81 | ||
|
|
7658c92cf9 | ||
|
|
85a5c78b80 | ||
|
|
9d7b47c784 | ||
|
|
e4c6ed9c60 | ||
|
|
fcfade4778 | ||
|
|
000e8bd12b | ||
|
|
ed8da2c760 | ||
|
|
fb6dc14e9b | ||
|
|
77e6e98234 | ||
|
|
fb3699ec5e | ||
|
|
4601be8b67 | ||
|
|
cc4d4adfb9 | ||
|
|
6a08623949 | ||
|
|
f0127ffc9a | ||
|
|
f1e513830c | ||
|
|
7de533a643 | ||
|
|
052127c473 | ||
|
|
7a4be5c0d2 | ||
|
|
a6208feed8 | ||
|
|
c8f55549d7 | ||
|
|
132a86dcb3 | ||
|
|
9f59baed10 | ||
|
|
ce56286329 | ||
|
|
6e76f2aff2 | ||
|
|
49edd58722 | ||
|
|
6a28aee13e | ||
|
|
79c70d09c9 | ||
|
|
b9bb97887b | ||
|
|
7df6d9f1aa | ||
|
|
587f83bc34 | ||
|
|
d81b2e6820 | ||
|
|
8315e0c74b | ||
|
|
3cc6690356 | ||
|
|
6507263b28 | ||
|
|
8fa0bb48df | ||
|
|
637a675681 | ||
|
|
085ada86e6 | ||
|
|
f59d430219 | ||
|
|
c415e5b893 | ||
|
|
67b6b3612c | ||
|
|
229b0e190f | ||
|
|
09d412cf2a | ||
|
|
2842cbf1e1 | ||
|
|
e2543bcf30 | ||
|
|
3f75aa6848 | ||
|
|
57719f3ce9 | ||
|
|
45677ac57c | ||
|
|
4eacbf37ff | ||
|
|
ef256ac276 | ||
|
|
2733e04039 | ||
|
|
e49ec82258 | ||
|
|
cf301eb1d9 | ||
|
|
98b9ba2b2e | ||
|
|
2126c64468 | ||
|
|
271a1b4f98 | ||
|
|
9be3c62c04 | ||
|
|
04bfa235a9 | ||
|
|
3b37ae1b4e | ||
|
|
c1cb93cd26 | ||
|
|
75fa161c46 | ||
|
|
d6d82cff33 | ||
|
|
5c266fecf9 | ||
|
|
7244978b24 | ||
|
|
623021dcff | ||
|
|
5af165fce9 | ||
|
|
9503fafc53 | ||
|
|
99fac21bdb | ||
|
|
bc95678c5e | ||
|
|
3f34f38635 | ||
|
|
30f771369b | ||
|
|
20bd059a6c | ||
|
|
f5eb406394 | ||
|
|
cbebac1d45 | ||
|
|
030da43ae3 | ||
|
|
b7f1394403 | ||
|
|
ceb6a09387 | ||
|
|
14ad800967 | ||
|
|
34d1f86f76 | ||
|
|
b9b9f8eae3 | ||
|
|
0de8596afe | ||
|
|
4dbd26ff66 | ||
|
|
d018ef9033 | ||
|
|
979c985804 | ||
|
|
291e9a3aee | ||
|
|
5861ca773e | ||
|
|
eb3b5f751a | ||
|
|
9bbfbf1c5f | ||
|
|
8cbd124b80 | ||
|
|
d137d0eed0 | ||
|
|
58c5db3b00 | ||
|
|
8750796f9f | ||
|
|
7d4bb45f94 | ||
|
|
db744444f2 | ||
|
|
b25d379ef4 | ||
|
|
e1e95f7ccd | ||
|
|
edd50420ec | ||
|
|
9af8fe085b | ||
|
|
ed6bb121bb | ||
|
|
4635b99153 | ||
|
|
282fde9a04 | ||
|
|
e9078eedbd | ||
|
|
501698d844 | ||
|
|
dd089b1b21 | ||
|
|
6260a1a28c | ||
|
|
8bc5035624 | ||
|
|
2dbfd9ea5a | ||
|
|
08e61d76d6 | ||
|
|
447127cee4 | ||
|
|
49ebbd05b5 | ||
|
|
defea962f6 | ||
|
|
b3866288e0 | ||
|
|
bed2ce69bb | ||
|
|
4d37d61851 | ||
|
|
8a48db6d0d | ||
|
|
ff0f645e54 | ||
|
|
6e0765fbaf | ||
|
|
1d03e0e9fc | ||
|
|
cac60a25bb | ||
|
|
57c65ec625 | ||
|
|
ffc3c61d00 | ||
|
|
aa3b16a136 | ||
|
|
6e0b408dd5 | ||
|
|
be9eeff6c2 | ||
|
|
ca9d92b1e5 | ||
|
|
0607db41e5 | ||
|
|
48b1829b14 | ||
|
|
6767a8f72c | ||
|
|
1e477af05f | ||
|
|
9b5e5f0f50 | ||
|
|
fb12f31df2 | ||
|
|
db2c6678e4 | ||
|
|
bc3421add8 | ||
|
|
61d8809a0f | ||
|
|
d37cc9f9c8 | ||
|
|
0db082f6d0 | ||
|
|
c94dc52310 | ||
|
|
bebcbfd80e | ||
|
|
dfc5e3609d | ||
|
|
fa5765ae82 | ||
|
|
852d851996 | ||
|
|
0b599b44b0 | ||
|
|
f06dc3ef90 | ||
|
|
f9df61e648 | ||
|
|
6e76e02dba | ||
|
|
dc24450e29 | ||
|
|
8bcecce627 | ||
|
|
66cb963df3 | ||
|
|
5c95c77604 | ||
|
|
a264a609db | ||
|
|
3a876fd437 | ||
|
|
13bc68a646 | ||
|
|
b41538d8c7 | ||
|
|
720480d05e | ||
|
|
71b1af69c5 | ||
|
|
18fd79fbe6 | ||
|
|
c16421df27 | ||
|
|
0d686fc6ae | ||
|
|
db352c0a18 | ||
|
|
bf7b18d442 | ||
|
|
7d56ca5294 | ||
|
|
0b1015e221 | ||
|
|
96f0d648fa | ||
|
|
c4996f9563 | ||
|
|
850c5fec32 | ||
|
|
b1f79c34d1 | ||
|
|
96a461646e | ||
|
|
5df94fd866 | ||
|
|
e074ba84d1 | ||
|
|
1335be8d60 | ||
|
|
c79d75b32d | ||
|
|
f18054847e | ||
|
|
b2b81f3822 | ||
|
|
90753b2782 | ||
|
|
c05fa9963a | ||
|
|
9de7a7d48f | ||
|
|
29cddc449f | ||
|
|
dfed14ba67 | ||
|
|
440262a51b | ||
|
|
d705fece9d | ||
|
|
d08cc48368 | ||
|
|
b94ad084c3 | ||
|
|
9453148233 | ||
|
|
1857d0e53f | ||
|
|
ae422c2628 | ||
|
|
d933116e46 | ||
|
|
5cf4afd7b2 | ||
|
|
f7853f3b27 | ||
|
|
913d85302c | ||
|
|
33daedd7aa | ||
|
|
cc219cc81c | ||
|
|
945295adc3 | ||
|
|
651cc81cfe | ||
|
|
3bd62f3fdf | ||
|
|
e3484c8dc3 | ||
|
|
eecbe533a1 | ||
|
|
4221e99362 | ||
|
|
5c69521973 | ||
|
|
ffcaa67a56 | ||
|
|
64a070f6b0 | ||
|
|
c61656c759 | ||
|
|
6d34e4e99b | ||
|
|
d3a767364b | ||
|
|
f3c6d1ca1d | ||
|
|
6098dc0242 | ||
|
|
29ec3c7d5c | ||
|
|
4597ab4efb | ||
|
|
1dddcf1194 | ||
|
|
c4c38a51d9 | ||
|
|
8a8c0703b1 | ||
|
|
1b74869b04 | ||
|
|
f065504ed6 | ||
|
|
3f5485605f | ||
|
|
399bb522e0 | ||
|
|
9fffa9a996 | ||
|
|
aee9a8366f | ||
|
|
c3eec7ea8a | ||
|
|
4b4ec3438f | ||
|
|
9aa43c9165 | ||
|
|
4ae23ed0f9 | ||
|
|
efe68d5aa6 | ||
|
|
1604db02b5 | ||
|
|
e7192de9c0 | ||
|
|
f822b38a00 | ||
|
|
5a5c7f38d1 | ||
|
|
42a9a88ae2 | ||
|
|
aea3fc6281 | ||
|
|
bcdc11396a | ||
|
|
ecd1d44d23 | ||
|
|
6df786248c | ||
|
|
37e75f7791 | ||
|
|
7ada2385b3 | ||
|
|
a77aab96f5 | ||
|
|
13af48800b | ||
|
|
6e7fb59638 | ||
|
|
863b4f8fe9 | ||
|
|
949ac9d930 | ||
|
|
06d1a2e2fd | ||
|
|
d478f62b49 | ||
|
|
128bc2241d | ||
|
|
b2730d680c | ||
|
|
52b180104a | ||
|
|
9cdb62da93 | ||
|
|
5af08edfda | ||
|
|
24fa5f33d7 | ||
|
|
caf0bf34dd | ||
|
|
181a1ae7f3 | ||
|
|
e18ecead2c | ||
|
|
36a26adab2 | ||
|
|
bc2edf5107 | ||
|
|
50bbac5973 | ||
|
|
45b221659b | ||
|
|
16957f14f1 | ||
|
|
0d7dde0639 | ||
|
|
37805184d9 | ||
|
|
df9932088f | ||
|
|
d101a83be8 | ||
|
|
94ea289c75 | ||
|
|
e2539e91eb | ||
|
|
77e9bae3ff | ||
|
|
d99644237b | ||
|
|
5cb268e99b | ||
|
|
f179b03d6e | ||
|
|
28fe58f3dd | ||
|
|
14acd05846 | ||
|
|
ccce135bf5 | ||
|
|
cb5607fc8c | ||
|
|
7f70d1de1c | ||
|
|
c36173f5a9 | ||
|
|
7acbe981e2 | ||
|
|
dd6ab7c68c | ||
|
|
a1ea256e79 | ||
|
|
14942c9ee9 | ||
|
|
b0b316ed48 | ||
|
|
871cfbd40c | ||
|
|
9a3ca0ce3b | ||
|
|
c90df5c12c | ||
|
|
f4acc78f66 | ||
|
|
3d5e2c5ca1 | ||
|
|
55bf9196dc | ||
|
|
18a52b4937 | ||
|
|
439727746c | ||
|
|
04b55177b5 | ||
|
|
2793ede875 | ||
|
|
dc4801c014 | ||
|
|
d5e2649608 | ||
|
|
4102f0bc9d | ||
|
|
25e4203cb1 | ||
|
|
e1a3ead941 | ||
|
|
6251090893 | ||
|
|
aa5e04b70e | ||
|
|
8ac25c29ee | ||
|
|
f4517d667b | ||
|
|
dc2481c805 | ||
|
|
8d7435a51b | ||
|
|
bb28c718df | ||
|
|
1b7a5b6209 | ||
|
|
448622b4fd | ||
|
|
e9dda03e8d | ||
|
|
8d3d177932 | ||
|
|
f0af4d692a | ||
|
|
075173e67d | ||
|
|
f02d575379 | ||
|
|
735ebf6c59 | ||
|
|
96f0b7abe3 | ||
|
|
eb1686f04b | ||
|
|
d4b5d9a02a | ||
|
|
f87f77ce7b | ||
|
|
24619e74f6 | ||
|
|
f5c1646f79 | ||
|
|
e26d77e78c | ||
|
|
8e1e81732a | ||
|
|
801f8c1592 | ||
|
|
fd4b234171 | ||
|
|
dff536ab6d | ||
|
|
a152ce45d3 | ||
|
|
6a164f8811 | ||
|
|
a03ff39f3e | ||
|
|
a6373e357a | ||
|
|
538b639bef | ||
|
|
fe0457b257 | ||
|
|
d5b228f234 | ||
|
|
1c2f95eeb6 | ||
|
|
81b3436ec4 | ||
|
|
3e4f2bcf14 | ||
|
|
c7696964b9 | ||
|
|
fb8ecf7b5a | ||
|
|
e3c2345b21 | ||
|
|
bfe0d14409 | ||
|
|
c7498c3a11 | ||
|
|
5fba41688a | ||
|
|
b63b9c32f7 | ||
|
|
65c6203ad7 | ||
|
|
3a18337129 | ||
|
|
b6b433626e | ||
|
|
5d6b9b0cb1 | ||
|
|
6d09330f98 | ||
|
|
5df9afa91a | ||
|
|
30a341331f | ||
|
|
31cf4b6619 | ||
|
|
dd0da3218c | ||
|
|
11c9219848 | ||
|
|
b1ffd2ef2b | ||
|
|
86cf7952fb | ||
|
|
d790d2b6bc | ||
|
|
a711a8e759 | ||
|
|
8a18b6e13b | ||
|
|
95aeb61d7c | ||
|
|
e8b0144cf7 | ||
|
|
2c8c1860ca | ||
|
|
5edfbd5305 | ||
|
|
4ceae655bd | ||
|
|
6ae76d108b | ||
|
|
9cc3cfb63e | ||
|
|
58e4c0793a | ||
|
|
80f2c1be67 | ||
|
|
8a5174d078 | ||
|
|
d0f357a690 | ||
|
|
fbe3df5658 | ||
|
|
21e3ef91eb | ||
|
|
3f116dc74b | ||
|
|
32731c4622 | ||
|
|
3c1f0e1aec | ||
|
|
685e48636d | ||
|
|
7c4edaa636 | ||
|
|
35867707d0 | ||
|
|
5b884d750f | ||
|
|
bc0d5f4e41 | ||
|
|
f20452622a | ||
|
|
6ba26cf7b5 | ||
|
|
7510e0654b | ||
|
|
564bb22d8b | ||
|
|
5e2d5f0d83 | ||
|
|
d90ffbcf14 | ||
|
|
771cc72dcf | ||
|
|
04c91111e9 | ||
|
|
5a13daefdb | ||
|
|
c033c05ec1 | ||
|
|
5b2f323a87 | ||
|
|
b855d95430 | ||
|
|
fe4b63210e | ||
|
|
84c09ec59d | ||
|
|
40e17ef801 | ||
|
|
f1fcb92691 | ||
|
|
3865555113 | ||
|
|
95e46806a4 | ||
|
|
c9c3d03878 | ||
|
|
b28ec4be6e | ||
|
|
29d7023fae | ||
|
|
22f6c23780 | ||
|
|
548db29a47 | ||
|
|
1089c5bf04 | ||
|
|
559cf6583f | ||
|
|
b04f92715c | ||
|
|
671aba6ab7 | ||
|
|
beaeb30dcc | ||
|
|
56abca1f41 | ||
|
|
52d5f219e1 | ||
|
|
d4516e942c | ||
|
|
1c17a16830 | ||
|
|
1f6ab13fc5 | ||
|
|
7344df87e5 | ||
|
|
29353bd7c2 | ||
|
|
7b6f5d6860 | ||
|
|
2ccb20bf3a | ||
|
|
34b7e5cbca | ||
|
|
a595e2df06 | ||
|
|
729e0e9b1e | ||
|
|
c03b790888 | ||
|
|
112b5f63dd | ||
|
|
334e5f19bf | ||
|
|
35bbf67175 | ||
|
|
9aec255ee9 | ||
|
|
b07e80e6ae | ||
|
|
ad2b910d73 | ||
|
|
f28a7218cd | ||
|
|
4164e1191e | ||
|
|
bd31c6f90b | ||
|
|
8f7bef9509 | ||
|
|
06c91fbcbd | ||
|
|
dab4e521af | ||
|
|
b20f61356c | ||
|
|
4ec23eea00 | ||
|
|
270fd9cb07 | ||
|
|
c7c5e07d43 | ||
|
|
c1ba83f0d4 | ||
|
|
d71200ee32 | ||
|
|
16ac05ebd5 | ||
|
|
ac77b9b735 | ||
|
|
0fa4b77ff8 | ||
|
|
6773dda657 | ||
|
|
bf42386c5b | ||
|
|
90fc06a494 | ||
|
|
8dfe693529 | ||
|
|
d65d27a6bb | ||
|
|
e6a6bde8e2 | ||
|
|
c7d0a7be04 | ||
|
|
e0f1b03cf0 | ||
|
|
902737b262 | ||
|
|
429cd05a0f | ||
|
|
46e7e99c5a | ||
|
|
d19ce15f3d | ||
|
|
49af7eb370 | ||
|
|
8e235dc92c | ||
|
|
3b3963b055 | ||
|
|
378c2afcd3 | ||
|
|
d709f20e1f | ||
|
|
99d9657af8 | ||
|
|
62efdd7f7a | ||
|
|
ebcf98c137 | ||
|
|
7560e2427d | ||
|
|
920a608e5d | ||
|
|
4dfb8b988c | ||
|
|
af6dae3498 | ||
|
|
ee21b4d435 | ||
|
|
654adccfbf | ||
|
|
b283a2b3d9 | ||
|
|
cce729916a | ||
|
|
4f8bf97935 | ||
|
|
ba88c7b25b | ||
|
|
0ec5d53e5b | ||
|
|
f3b415c095 | ||
|
|
6fb657a89e | ||
|
|
90240cb6db | ||
|
|
cca48f07aa | ||
|
|
beff639c3d | ||
|
|
00359830c2 | ||
|
|
f23e098b9a | ||
|
|
42f75b6602 | ||
|
|
4f65cc312d | ||
|
|
854a091f82 | ||
|
|
63dbc7c63d | ||
|
|
a4e80640fe | ||
|
|
fe0a139c89 | ||
|
|
ac2616545b | ||
|
|
c9e7922a14 | ||
|
|
12a7402291 | ||
|
|
33d7b48e49 | ||
|
|
ee89e9eb2f | ||
|
|
e793f9e871 | ||
|
|
18b02370a2 | ||
|
|
d53399e546 | ||
|
|
622d12137a | ||
|
|
bae8e44b32 | ||
|
|
24b5387fd1 | ||
|
|
0c65824cad | ||
|
|
31c9d9da3f | ||
|
|
8f854e6a45 | ||
|
|
75b3f5ac5a | ||
|
|
323e183775 | ||
|
|
380ef52331 | ||
|
|
b8862293b6 | ||
|
|
85f1cf1d90 | ||
|
|
1d4e36d58f | ||
|
|
90ae5e5865 | ||
|
|
755fb96a33 | ||
|
|
b8ca480b07 | ||
|
|
8a5fbf183b | ||
|
|
91318d3d04 | ||
|
|
a33d04d1ac | ||
|
|
02222752f0 | ||
|
|
04d94e3337 | ||
|
|
b98c36db48 | ||
|
|
d05d11e67f | ||
|
|
3370736e09 | ||
|
|
cc5a315039 | ||
|
|
6ea10cdaaf | ||
|
|
9643fa1c9a | ||
|
|
937a58d0dd | ||
|
|
d9faa1329a | ||
|
|
fec09e7ed3 | ||
|
|
31b15b492e | ||
|
|
f96bd4eb18 | ||
|
|
a4109088c9 | ||
|
|
f827e8e1b7 | ||
|
|
82f2f76dc4 | ||
|
|
e6a44a0860 | ||
|
|
604651873e | ||
|
|
9114881623 | ||
|
|
080cdda4fa | ||
|
|
32f4d1af8b | ||
|
|
1bfa8e6662 | ||
|
|
7c97ea4a9e | ||
|
|
bea11b08d7 | ||
|
|
8547032a87 | ||
|
|
43574c852d | ||
|
|
5ecc006805 | ||
|
|
15413108f0 | ||
|
|
831c888b84 | ||
|
|
f0ed09a8d4 | ||
|
|
a80f30f9ef | ||
|
|
fd2f0df097 | ||
|
|
d72a3e1879 | ||
|
|
4a6903fdb4 | ||
|
|
8106df1d7d | ||
|
|
5e3e6b0bd8 | ||
|
|
a06d2892f8 | ||
|
|
e377e90666 | ||
|
|
19cc67561b | ||
|
|
92f2ca1866 | ||
|
|
1949074e2f | ||
|
|
1c0068e95b | ||
|
|
4b43196295 | ||
|
|
2c3cf9a25e | ||
|
|
67fbfc0b8f | ||
|
|
6e6198c64e | ||
|
|
6b677c16ce | ||
|
|
973b937ba5 | ||
|
|
48597ef193 | ||
|
|
ffbc007f82 | ||
|
|
8fc88f8cbf | ||
|
|
a4b932c78b | ||
|
|
2ff4af8ce3 | ||
|
|
6795015d00 | ||
|
|
b100ce15cd | ||
|
|
3edf1e2f59 | ||
|
|
4d49db0ff9 | ||
|
|
7da22e4915 | ||
|
|
8d4a9df6b1 | ||
|
|
f620e78b20 | ||
|
|
8df80781d9 | ||
|
|
edec065fee | ||
|
|
0fe529c3aa | ||
|
|
bcfdd07f85 | ||
|
|
a9a118aaf9 | ||
|
|
60c86dd8d1 | ||
|
|
8feef2c1a9 | ||
|
|
4ba99db88c | ||
|
|
b4801adfbd | ||
|
|
08e8f8676e | ||
|
|
2dca0c20db | ||
|
|
6f57aa3f53 | ||
|
|
1aafe915e4 | ||
|
|
6d4d25ee6f | ||
|
|
6b94d30a5f | ||
|
|
1a9798c559 | ||
|
|
764436ed8e | ||
|
|
2a1c5ff57b | ||
|
|
cc4ba1a3a9 | ||
|
|
d68a9f1850 | ||
|
|
4f460160d2 | ||
|
|
d5ff89f6d3 | ||
|
|
452588dded | ||
|
|
aef862d9ce | ||
|
|
896f3252b8 | ||
|
|
6853a699e1 | ||
|
|
cd07eef639 | ||
|
|
ef9a741781 | ||
|
|
c5de91ba94 | ||
|
|
bc1e6e011b | ||
|
|
906028b1fb | ||
|
|
034602969f | ||
|
|
4ca14bfdad | ||
|
|
59f56d8c94 | ||
|
|
63d26f0478 | ||
|
|
eae65e55ce | ||
|
|
0edf06329f | ||
|
|
6943a379c9 | ||
|
|
e49534b70c | ||
|
|
344616ca2f | ||
|
|
0e287a9c93 | ||
|
|
8141f53af5 | ||
|
|
5a6cb0d887 | ||
|
|
26e7677595 | ||
|
|
814b0e1fe8 | ||
|
|
a173dc5c9d | ||
|
|
a567facf2b | ||
|
|
e76d80defe | ||
|
|
4a17025467 | ||
|
|
bd1fcd3525 | ||
|
|
0cb0cea167 | ||
|
|
ee68a685a7 | ||
|
|
c78bd492af | ||
|
|
6857bb4406 | ||
|
|
dcf3ee6982 | ||
|
|
76850749e4 | ||
|
|
91e5e33440 | ||
|
|
11e55088c9 | ||
|
|
57c0bc9fb6 | ||
|
|
c3ebb22a4b | ||
|
|
1562d00037 | ||
|
|
e9e843b27d | ||
|
|
ec33b9908e | ||
|
|
67004368d9 | ||
|
|
94ecbd44e4 | ||
|
|
ba76312248 | ||
|
|
50bff270b6 | ||
|
|
bd5cf1c272 | ||
|
|
d22404994a | ||
|
|
9898730cc5 | ||
|
|
b0f1e55a87 | ||
|
|
6566824807 | ||
|
|
9249a2af0d | ||
|
|
112fc3b1d1 | ||
|
|
37299b3bd7 | ||
|
|
8f65ce995a | ||
|
|
4a743e6dc1 | ||
|
|
07dda61929 | ||
|
|
0d8438ef40 | ||
|
|
96bb638969 | ||
|
|
e74962272e | ||
|
|
5a15419baf | ||
|
|
e8403977b9 | ||
|
|
add2ca85f2 | ||
|
|
fbb7b02e90 | ||
|
|
249b62c9de | ||
|
|
b433322e8d | ||
|
|
1c8850fc95 | ||
|
|
dc16f1b65a | ||
|
|
ff30395dc1 | ||
|
|
8e600f3302 | ||
|
|
5a1e0a8379 | ||
|
|
2a3ce6baa9 | ||
|
|
01b2f9cff6 | ||
|
|
ac38614171 | ||
|
|
eb95c5cd07 | ||
|
|
a799b54b9e | ||
|
|
98ba0236e6 | ||
|
|
b6c552df07 | ||
|
|
e2827e475d | ||
|
|
58cbd337b5 | ||
|
|
a91e59d544 | ||
|
|
814787677a | ||
|
|
85caa5bd0c | ||
|
|
e04083fc0e | ||
|
|
cf532e5e0d | ||
|
|
c097fc2c48 | ||
|
|
0371d71409 | ||
|
|
81ef7343d4 | ||
|
|
8e4b59c90c | ||
|
|
68f73410fc | ||
|
|
88af8ed374 | ||
|
|
015f82878e | ||
|
|
3874e58dc2 | ||
|
|
9f8c159583 | ||
|
|
d8f6f9ce19 | ||
|
|
eab03e63d4 | ||
|
|
461829274a | ||
|
|
e751c0c535 | ||
|
|
1fffc79c32 | ||
|
|
83fab4bc19 | ||
|
|
f60e28d2f5 | ||
|
|
a62d7aa3ee | ||
|
|
cc84a45244 | ||
|
|
5cf3d24018 | ||
|
|
4bdbe617fe | ||
|
|
33c867fd8c | ||
|
|
2013ceb9d2 | ||
|
|
7120c6414c | ||
|
|
5ce7b2d98d | ||
|
|
cb82198271 | ||
|
|
5e5ffaa416 | ||
|
|
4b253e1f73 | ||
|
|
dd929dbf0e | ||
|
|
97a9d34e96 | ||
|
|
602070ec9c | ||
|
|
afd8989150 | ||
|
|
694197a701 | ||
|
|
2f08306695 | ||
|
|
6acc77d86d | ||
|
|
5ddd5e49ee | ||
|
|
72f9e77368 | ||
|
|
a46c9238fa | ||
|
|
87120ad4ac | ||
|
|
7544b5ec9a | ||
|
|
ff4a62d1e7 | ||
|
|
41daa51988 | ||
|
|
d522350c99 | ||
|
|
1d1bb9451e | ||
|
|
1fce1a61d4 | ||
|
|
883a6caf96 | ||
|
|
a239c39f09 | ||
|
|
e925a8ab99 | ||
|
|
bccaf939e6 | ||
|
|
676648e0b3 | ||
|
|
4ae19e6dde | ||
|
|
4d0ff5c281 | ||
|
|
327b354cc2 | ||
|
|
6d307cc9fc | ||
|
|
adc7134af5 | ||
|
|
10f19cd0c2 | ||
|
|
9ed45594c6 | ||
|
|
c138f4c3a6 | ||
|
|
a35be05790 | ||
|
|
60b5ed8e5d | ||
|
|
d8ddbc4d87 | ||
|
|
19c0fc85e2 | ||
|
|
a58df35ead | ||
|
|
9789bd02d8 | ||
|
|
d94e54923f | ||
|
|
64c7be59b7 | ||
|
|
89ad6ad902 | ||
|
|
4f73bc9693 | ||
|
|
add6b79231 | ||
|
|
c90dad566f | ||
|
|
5cbe6bf8f8 | ||
|
|
4ef6ff217e | ||
|
|
87abfbf515 | ||
|
|
73e65fd838 | ||
|
|
e53edb0fc2 | ||
|
|
17908fbf6b | ||
|
|
3dae108f84 | ||
|
+40 |
5bbf685035 | ||
|
|
a63d1e87b1 | ||
|
|
7129de98cd | ||
|
|
2984dbc0df | ||
|
|
392db7f611 | ||
|
|
5a427b8daa | ||
|
|
18f2e6f166 | ||
|
|
e78903302f | ||
|
|
4084ade86c | ||
|
|
6b0d919dbd | ||
|
|
a7b558b38b | ||
|
|
6aed7e3ff4 | ||
|
|
8e93a8a2e2 | ||
|
|
e38a86e37b | ||
|
|
392e3530bf | ||
|
|
833c902b2b | ||
|
|
6eaea64b3f | ||
|
|
5303b50737 | ||
|
|
6acbcfe679 | ||
|
|
16ef5ebb97 | ||
|
|
acfb95f9c2 | ||
|
|
aacea166d7 | ||
|
|
f7bb3b852a | ||
|
|
d4ff1e031a | ||
|
|
6a3d135d49 | ||
|
|
5c4bf7aabd | ||
|
|
e9c7dc7464 | ||
|
|
74ad21b145 | ||
|
|
f214eeb7b1 | ||
|
|
ae25f90f34 |
@@ -26,13 +26,20 @@ import userEvent from '@testing-library/user-event'
|
||||
// WHY: Mocks must be hoisted to top of file (Jest requirement).
|
||||
// They run BEFORE imports, so keep them before component imports.
|
||||
|
||||
// i18n (always required in Dify)
|
||||
// WHY: Returns key instead of translation so tests don't depend on i18n files
|
||||
jest.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string) => key,
|
||||
}),
|
||||
}))
|
||||
// i18n (automatically mocked)
|
||||
// WHY: Shared mock at web/__mocks__/react-i18next.ts is auto-loaded by Jest
|
||||
// No explicit mock needed - it returns translation keys as-is
|
||||
// Override only if custom translations are required:
|
||||
// jest.mock('react-i18next', () => ({
|
||||
// useTranslation: () => ({
|
||||
// t: (key: string) => {
|
||||
// const customTranslations: Record<string, string> = {
|
||||
// 'my.custom.key': 'Custom Translation',
|
||||
// }
|
||||
// return customTranslations[key] || key
|
||||
// },
|
||||
// }),
|
||||
// }))
|
||||
|
||||
// Router (if component uses useRouter, usePathname, useSearchParams)
|
||||
// WHY: Isolates tests from Next.js routing, enables testing navigation behavior
|
||||
|
||||
@@ -93,4 +93,12 @@ jobs:
|
||||
# Create a detailed coverage summary
|
||||
echo "### Test Coverage Summary :test_tube:" >> $GITHUB_STEP_SUMMARY
|
||||
echo "Total Coverage: ${TOTAL_COVERAGE}%" >> $GITHUB_STEP_SUMMARY
|
||||
uv run --project api coverage report --format=markdown >> $GITHUB_STEP_SUMMARY
|
||||
{
|
||||
echo ""
|
||||
echo "<details><summary>File-level coverage (click to expand)</summary>"
|
||||
echo ""
|
||||
echo '```'
|
||||
uv run --project api coverage report -m
|
||||
echo '```'
|
||||
echo "</details>"
|
||||
} >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
+1
-1
@@ -670,4 +670,4 @@ ANNOTATION_IMPORT_MIN_RECORDS=1
|
||||
ANNOTATION_IMPORT_RATE_LIMIT_PER_MINUTE=5
|
||||
ANNOTATION_IMPORT_RATE_LIMIT_PER_HOUR=20
|
||||
# Maximum number of concurrent annotation import tasks per tenant
|
||||
ANNOTATION_IMPORT_MAX_CONCURRENT=5
|
||||
ANNOTATION_IMPORT_MAX_CONCURRENT=5
|
||||
|
||||
@@ -54,6 +54,7 @@ from .app import (
|
||||
completion,
|
||||
conversation,
|
||||
conversation_variables,
|
||||
enduser_auth,
|
||||
generator,
|
||||
mcp_server,
|
||||
message,
|
||||
@@ -162,6 +163,7 @@ __all__ = [
|
||||
"datasource_content_preview",
|
||||
"email_register",
|
||||
"endpoint",
|
||||
"enduser_auth",
|
||||
"extension",
|
||||
"external",
|
||||
"feature",
|
||||
|
||||
@@ -0,0 +1,230 @@
|
||||
"""
|
||||
End-user authentication API controllers.
|
||||
|
||||
Provides API endpoints for managing end-user credentials for tool authentication.
|
||||
"""
|
||||
|
||||
from flask import request
|
||||
from flask_restx import Resource
|
||||
from werkzeug.exceptions import BadRequest
|
||||
|
||||
from controllers.console import console_ns
|
||||
from controllers.console.app.wraps import get_app_model
|
||||
from controllers.console.wraps import account_initialization_required, setup_required
|
||||
from core.model_runtime.utils.encoders import jsonable_encoder
|
||||
from libs.login import current_account_with_tenant, login_required
|
||||
from models import AppMode
|
||||
from services.tools.app_auth_requirement_service import AppAuthRequirementService
|
||||
from services.tools.enduser_auth_service import EndUserAuthService
|
||||
from services.tools.enduser_oauth_service import EndUserOAuthService
|
||||
|
||||
|
||||
@console_ns.route("/apps/<uuid:app_id>/auth/providers")
|
||||
class AppAuthProvidersApi(Resource):
|
||||
"""
|
||||
Get list of authentication providers required for an app.
|
||||
|
||||
Returns providers that require end-user authentication based on app configuration.
|
||||
"""
|
||||
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@get_app_model(mode=[AppMode.CHAT, AppMode.AGENT_CHAT, AppMode.ADVANCED_CHAT, AppMode.COMPLETION, AppMode.WORKFLOW])
|
||||
def get(self, app_model):
|
||||
"""Get authentication providers required for the app."""
|
||||
_, tenant_id = current_account_with_tenant()
|
||||
|
||||
providers = AppAuthRequirementService.get_required_providers(
|
||||
tenant_id=tenant_id,
|
||||
app_id=str(app_model.id),
|
||||
)
|
||||
|
||||
return jsonable_encoder(providers)
|
||||
|
||||
|
||||
@console_ns.route("/apps/<uuid:app_id>/auth/providers/<path:provider_id>/credentials")
|
||||
class AppAuthProviderCredentialsApi(Resource):
|
||||
"""
|
||||
Manage end-user credentials for a specific provider.
|
||||
|
||||
Allows listing, creating, and deleting end-user credentials.
|
||||
"""
|
||||
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@get_app_model(mode=[AppMode.CHAT, AppMode.AGENT_CHAT, AppMode.ADVANCED_CHAT, AppMode.COMPLETION, AppMode.WORKFLOW])
|
||||
def get(self, app_model, provider_id: str):
|
||||
"""List end-user's credentials for this provider."""
|
||||
user, tenant_id = current_account_with_tenant()
|
||||
|
||||
# For console API, use the current account user as end_user_id
|
||||
# In production, this would be the actual end-user ID from the chat/completion request
|
||||
end_user_id = str(user.id)
|
||||
|
||||
credentials = EndUserAuthService.list_credentials(
|
||||
tenant_id=tenant_id,
|
||||
end_user_id=end_user_id,
|
||||
provider_id=provider_id,
|
||||
)
|
||||
|
||||
return jsonable_encoder(credentials)
|
||||
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@get_app_model(mode=[AppMode.CHAT, AppMode.AGENT_CHAT, AppMode.ADVANCED_CHAT, AppMode.COMPLETION, AppMode.WORKFLOW])
|
||||
def post(self, app_model, provider_id: str):
|
||||
"""Create a new credential (API key only)."""
|
||||
user, tenant_id = current_account_with_tenant()
|
||||
end_user_id = str(user.id)
|
||||
|
||||
payload = request.get_json()
|
||||
if not payload:
|
||||
raise BadRequest("Request body is required")
|
||||
|
||||
credential_type = payload.get("credential_type")
|
||||
credentials = payload.get("credentials")
|
||||
|
||||
if not credential_type or not credentials:
|
||||
raise BadRequest("credential_type and credentials are required")
|
||||
|
||||
if credential_type != "api-key":
|
||||
raise BadRequest(
|
||||
"Only 'api-key' credential type can be created via this endpoint. "
|
||||
"Use OAuth flow for OAuth credentials."
|
||||
)
|
||||
|
||||
credential = EndUserAuthService.create_api_key_credential(
|
||||
tenant_id=tenant_id,
|
||||
end_user_id=end_user_id,
|
||||
provider_id=provider_id,
|
||||
credentials=credentials,
|
||||
)
|
||||
|
||||
return jsonable_encoder(credential)
|
||||
|
||||
|
||||
@console_ns.route("/apps/<uuid:app_id>/auth/providers/<path:provider_id>/credentials/<string:credential_id>")
|
||||
class AppAuthProviderCredentialApi(Resource):
|
||||
"""
|
||||
Manage a specific end-user credential.
|
||||
|
||||
Allows getting, updating, or deleting a credential.
|
||||
"""
|
||||
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@get_app_model(mode=[AppMode.CHAT, AppMode.AGENT_CHAT, AppMode.ADVANCED_CHAT, AppMode.COMPLETION, AppMode.WORKFLOW])
|
||||
def delete(self, app_model, provider_id: str, credential_id: str):
|
||||
"""Delete a credential."""
|
||||
user, tenant_id = current_account_with_tenant()
|
||||
end_user_id = str(user.id)
|
||||
|
||||
EndUserAuthService.delete_credential(
|
||||
tenant_id=tenant_id,
|
||||
end_user_id=end_user_id,
|
||||
provider_id=provider_id,
|
||||
credential_id=credential_id,
|
||||
)
|
||||
|
||||
return {"result": "success"}
|
||||
|
||||
|
||||
@console_ns.route("/apps/<uuid:app_id>/auth/oauth/<path:provider_id>/authorization-url")
|
||||
class AppAuthOAuthAuthorizationUrlApi(Resource):
|
||||
"""
|
||||
Get OAuth authorization URL for end-user authentication.
|
||||
|
||||
Returns the URL where the user should be redirected to authenticate with the provider.
|
||||
"""
|
||||
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@get_app_model(mode=[AppMode.CHAT, AppMode.AGENT_CHAT, AppMode.ADVANCED_CHAT, AppMode.COMPLETION, AppMode.WORKFLOW])
|
||||
def get(self, app_model, provider_id: str):
|
||||
"""Get OAuth authorization URL."""
|
||||
user, tenant_id = current_account_with_tenant()
|
||||
end_user_id = str(user.id)
|
||||
|
||||
result = EndUserOAuthService.get_authorization_url(
|
||||
end_user_id=end_user_id,
|
||||
tenant_id=tenant_id,
|
||||
app_id=str(app_model.id),
|
||||
provider=provider_id,
|
||||
)
|
||||
|
||||
# Set OAuth context cookie for callback
|
||||
response = jsonable_encoder({
|
||||
"authorization_url": result["authorization_url"],
|
||||
})
|
||||
|
||||
# Store context_id in response for frontend to set as cookie
|
||||
response["context_id"] = result["context_id"]
|
||||
|
||||
return response
|
||||
|
||||
|
||||
@console_ns.route("/apps/<uuid:app_id>/auth/oauth/<path:provider_id>/callback")
|
||||
class AppAuthOAuthCallbackApi(Resource):
|
||||
"""
|
||||
Handle OAuth callback for end-user authentication.
|
||||
|
||||
This endpoint is called by the OAuth provider after user authorization.
|
||||
"""
|
||||
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@get_app_model(mode=[AppMode.CHAT, AppMode.AGENT_CHAT, AppMode.ADVANCED_CHAT, AppMode.COMPLETION, AppMode.WORKFLOW])
|
||||
def get(self, app_model, provider_id: str):
|
||||
"""Handle OAuth callback and store credentials."""
|
||||
# Get OAuth context ID from cookie
|
||||
context_id = request.cookies.get("oauth_context_id")
|
||||
|
||||
if not context_id:
|
||||
raise BadRequest("Missing OAuth context")
|
||||
|
||||
# Get OAuth error if any
|
||||
error = request.args.get("error")
|
||||
error_description = request.args.get("error_description")
|
||||
|
||||
if error:
|
||||
raise BadRequest(f"OAuth error: {error} - {error_description}")
|
||||
|
||||
# Handle callback and create credential
|
||||
result = EndUserOAuthService.handle_oauth_callback(
|
||||
context_id=context_id,
|
||||
request=request,
|
||||
)
|
||||
|
||||
return jsonable_encoder(result)
|
||||
|
||||
|
||||
@console_ns.route("/apps/<uuid:app_id>/auth/providers/<path:provider_id>/credentials/<string:credential_id>/refresh")
|
||||
class AppAuthProviderRefreshApi(Resource):
|
||||
"""
|
||||
Manually refresh OAuth token for a credential.
|
||||
|
||||
This endpoint allows refreshing an expired OAuth token.
|
||||
"""
|
||||
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@get_app_model(mode=[AppMode.CHAT, AppMode.AGENT_CHAT, AppMode.ADVANCED_CHAT, AppMode.COMPLETION, AppMode.WORKFLOW])
|
||||
def post(self, app_model, provider_id: str, credential_id: str):
|
||||
"""Refresh OAuth token."""
|
||||
user, tenant_id = current_account_with_tenant()
|
||||
end_user_id = str(user.id)
|
||||
|
||||
result = EndUserOAuthService.refresh_oauth_token(
|
||||
credential_id=credential_id,
|
||||
end_user_id=end_user_id,
|
||||
tenant_id=tenant_id,
|
||||
)
|
||||
|
||||
return jsonable_encoder(result)
|
||||
@@ -22,7 +22,12 @@ from controllers.console.error import (
|
||||
NotAllowedCreateWorkspace,
|
||||
WorkspacesLimitExceeded,
|
||||
)
|
||||
from controllers.console.wraps import email_password_login_enabled, setup_required
|
||||
from controllers.console.wraps import (
|
||||
decrypt_code_field,
|
||||
decrypt_password_field,
|
||||
email_password_login_enabled,
|
||||
setup_required,
|
||||
)
|
||||
from events.tenant_event import tenant_was_created
|
||||
from libs.helper import EmailStr, extract_remote_ip
|
||||
from libs.login import current_account_with_tenant
|
||||
@@ -79,6 +84,7 @@ class LoginApi(Resource):
|
||||
@setup_required
|
||||
@email_password_login_enabled
|
||||
@console_ns.expect(console_ns.models[LoginPayload.__name__])
|
||||
@decrypt_password_field
|
||||
def post(self):
|
||||
"""Authenticate user and login."""
|
||||
args = LoginPayload.model_validate(console_ns.payload)
|
||||
@@ -218,6 +224,7 @@ class EmailCodeLoginSendEmailApi(Resource):
|
||||
class EmailCodeLoginApi(Resource):
|
||||
@setup_required
|
||||
@console_ns.expect(console_ns.models[EmailCodeLoginPayload.__name__])
|
||||
@decrypt_code_field
|
||||
def post(self):
|
||||
args = EmailCodeLoginPayload.model_validate(console_ns.payload)
|
||||
|
||||
|
||||
@@ -140,6 +140,18 @@ class DataSourceNotionListApi(Resource):
|
||||
credential_id = request.args.get("credential_id", default=None, type=str)
|
||||
if not credential_id:
|
||||
raise ValueError("Credential id is required.")
|
||||
|
||||
# Get datasource_parameters from query string (optional, for GitHub and other datasources)
|
||||
datasource_parameters_str = request.args.get("datasource_parameters", default=None, type=str)
|
||||
datasource_parameters = {}
|
||||
if datasource_parameters_str:
|
||||
try:
|
||||
datasource_parameters = json.loads(datasource_parameters_str)
|
||||
if not isinstance(datasource_parameters, dict):
|
||||
raise ValueError("datasource_parameters must be a JSON object.")
|
||||
except json.JSONDecodeError:
|
||||
raise ValueError("Invalid datasource_parameters JSON format.")
|
||||
|
||||
datasource_provider_service = DatasourceProviderService()
|
||||
credential = datasource_provider_service.get_datasource_credentials(
|
||||
tenant_id=current_tenant_id,
|
||||
@@ -187,7 +199,7 @@ class DataSourceNotionListApi(Resource):
|
||||
online_document_result: Generator[OnlineDocumentPagesMessage, None, None] = (
|
||||
datasource_runtime.get_online_document_pages(
|
||||
user_id=current_user.id,
|
||||
datasource_parameters={},
|
||||
datasource_parameters=datasource_parameters,
|
||||
provider_type=datasource_runtime.datasource_provider_type(),
|
||||
)
|
||||
)
|
||||
@@ -218,14 +230,14 @@ class DataSourceNotionListApi(Resource):
|
||||
|
||||
|
||||
@console_ns.route(
|
||||
"/notion/workspaces/<uuid:workspace_id>/pages/<uuid:page_id>/<string:page_type>/preview",
|
||||
"/notion/pages/<uuid:page_id>/<string:page_type>/preview",
|
||||
"/datasets/notion-indexing-estimate",
|
||||
)
|
||||
class DataSourceNotionApi(Resource):
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
def get(self, workspace_id, page_id, page_type):
|
||||
def get(self, page_id, page_type):
|
||||
_, current_tenant_id = current_account_with_tenant()
|
||||
|
||||
credential_id = request.args.get("credential_id", default=None, type=str)
|
||||
@@ -239,11 +251,10 @@ class DataSourceNotionApi(Resource):
|
||||
plugin_id="langgenius/notion_datasource",
|
||||
)
|
||||
|
||||
workspace_id = str(workspace_id)
|
||||
page_id = str(page_id)
|
||||
|
||||
extractor = NotionExtractor(
|
||||
notion_workspace_id=workspace_id,
|
||||
notion_workspace_id="",
|
||||
notion_obj_id=page_id,
|
||||
notion_page_type=page_type,
|
||||
notion_access_token=credential.get("integration_secret"),
|
||||
|
||||
@@ -4,7 +4,7 @@ from typing import Any, Literal, cast
|
||||
from uuid import UUID
|
||||
|
||||
from flask import abort, request
|
||||
from flask_restx import Resource, marshal_with # type: ignore
|
||||
from flask_restx import Resource, marshal_with, reqparse # type: ignore
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy.orm import Session
|
||||
from werkzeug.exceptions import Forbidden, InternalServerError, NotFound
|
||||
@@ -975,6 +975,11 @@ class RagPipelineRecommendedPluginApi(Resource):
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
def get(self):
|
||||
parser = reqparse.RequestParser()
|
||||
parser.add_argument("type", type=str, location="args", required=False, default="all")
|
||||
args = parser.parse_args()
|
||||
type = args["type"]
|
||||
|
||||
rag_pipeline_service = RagPipelineService()
|
||||
recommended_plugins = rag_pipeline_service.get_recommended_plugins()
|
||||
recommended_plugins = rag_pipeline_service.get_recommended_plugins(type)
|
||||
return recommended_plugins
|
||||
|
||||
@@ -9,10 +9,12 @@ from typing import ParamSpec, TypeVar
|
||||
from flask import abort, request
|
||||
|
||||
from configs import dify_config
|
||||
from controllers.console.auth.error import AuthenticationFailedError, EmailCodeError
|
||||
from controllers.console.workspace.error import AccountNotInitializedError
|
||||
from enums.cloud_plan import CloudPlan
|
||||
from extensions.ext_database import db
|
||||
from extensions.ext_redis import redis_client
|
||||
from libs.encryption import FieldEncryption
|
||||
from libs.login import current_account_with_tenant
|
||||
from models.account import AccountStatus
|
||||
from models.dataset import RateLimitLog
|
||||
@@ -25,6 +27,14 @@ from .error import NotInitValidateError, NotSetupError, UnauthorizedAndForceLogo
|
||||
P = ParamSpec("P")
|
||||
R = TypeVar("R")
|
||||
|
||||
# Field names for decryption
|
||||
FIELD_NAME_PASSWORD = "password"
|
||||
FIELD_NAME_CODE = "code"
|
||||
|
||||
# Error messages for decryption failures
|
||||
ERROR_MSG_INVALID_ENCRYPTED_DATA = "Invalid encrypted data"
|
||||
ERROR_MSG_INVALID_ENCRYPTED_CODE = "Invalid encrypted code"
|
||||
|
||||
|
||||
def account_initialization_required(view: Callable[P, R]):
|
||||
@wraps(view)
|
||||
@@ -419,3 +429,75 @@ def annotation_import_concurrency_limit(view: Callable[P, R]):
|
||||
return view(*args, **kwargs)
|
||||
|
||||
return decorated
|
||||
|
||||
|
||||
def _decrypt_field(field_name: str, error_class: type[Exception], error_message: str) -> None:
|
||||
"""
|
||||
Helper to decode a Base64 encoded field in the request payload.
|
||||
|
||||
Args:
|
||||
field_name: Name of the field to decode
|
||||
error_class: Exception class to raise on decoding failure
|
||||
error_message: Error message to include in the exception
|
||||
"""
|
||||
if not request or not request.is_json:
|
||||
return
|
||||
# Get the payload dict - it's cached and mutable
|
||||
payload = request.get_json()
|
||||
if not payload or field_name not in payload:
|
||||
return
|
||||
encoded_value = payload[field_name]
|
||||
decoded_value = FieldEncryption.decrypt_field(encoded_value)
|
||||
|
||||
# If decoding failed, raise error immediately
|
||||
if decoded_value is None:
|
||||
raise error_class(error_message)
|
||||
|
||||
# Update payload dict in-place with decoded value
|
||||
# Since payload is a mutable dict and get_json() returns the cached reference,
|
||||
# modifying it will affect all subsequent accesses including console_ns.payload
|
||||
payload[field_name] = decoded_value
|
||||
|
||||
|
||||
def decrypt_password_field(view: Callable[P, R]):
|
||||
"""
|
||||
Decorator to decrypt password field in request payload.
|
||||
|
||||
Automatically decrypts the 'password' field if encryption is enabled.
|
||||
If decryption fails, raises AuthenticationFailedError.
|
||||
|
||||
Usage:
|
||||
@decrypt_password_field
|
||||
def post(self):
|
||||
args = LoginPayload.model_validate(console_ns.payload)
|
||||
# args.password is now decrypted
|
||||
"""
|
||||
|
||||
@wraps(view)
|
||||
def decorated(*args: P.args, **kwargs: P.kwargs):
|
||||
_decrypt_field(FIELD_NAME_PASSWORD, AuthenticationFailedError, ERROR_MSG_INVALID_ENCRYPTED_DATA)
|
||||
return view(*args, **kwargs)
|
||||
|
||||
return decorated
|
||||
|
||||
|
||||
def decrypt_code_field(view: Callable[P, R]):
|
||||
"""
|
||||
Decorator to decrypt verification code field in request payload.
|
||||
|
||||
Automatically decrypts the 'code' field if encryption is enabled.
|
||||
If decryption fails, raises EmailCodeError.
|
||||
|
||||
Usage:
|
||||
@decrypt_code_field
|
||||
def post(self):
|
||||
args = EmailCodeLoginPayload.model_validate(console_ns.payload)
|
||||
# args.code is now decrypted
|
||||
"""
|
||||
|
||||
@wraps(view)
|
||||
def decorated(*args: P.args, **kwargs: P.kwargs):
|
||||
_decrypt_field(FIELD_NAME_CODE, EmailCodeError, ERROR_MSG_INVALID_ENCRYPTED_CODE)
|
||||
return view(*args, **kwargs)
|
||||
|
||||
return decorated
|
||||
|
||||
@@ -3,7 +3,7 @@ from typing import Any, Union
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from core.tools.entities.tool_entities import ToolInvokeMessage, ToolProviderType
|
||||
from core.tools.entities.tool_entities import ToolAuthType, ToolInvokeMessage, ToolProviderType
|
||||
|
||||
|
||||
class AgentToolEntity(BaseModel):
|
||||
@@ -17,6 +17,7 @@ class AgentToolEntity(BaseModel):
|
||||
tool_parameters: dict[str, Any] = Field(default_factory=dict)
|
||||
plugin_unique_identifier: str | None = None
|
||||
credential_id: str | None = None
|
||||
auth_type: ToolAuthType = ToolAuthType.WORKSPACE
|
||||
|
||||
|
||||
class AgentPromptEntity(BaseModel):
|
||||
|
||||
@@ -10,7 +10,7 @@ class NotionInfo(BaseModel):
|
||||
"""
|
||||
|
||||
credential_id: str | None = None
|
||||
notion_workspace_id: str
|
||||
notion_workspace_id: str | None = ""
|
||||
notion_obj_id: str
|
||||
notion_page_type: str
|
||||
document: Document | None = None
|
||||
|
||||
@@ -166,7 +166,7 @@ class ExtractProcessor:
|
||||
elif extract_setting.datasource_type == DatasourceType.NOTION:
|
||||
assert extract_setting.notion_info is not None, "notion_info is required"
|
||||
extractor = NotionExtractor(
|
||||
notion_workspace_id=extract_setting.notion_info.notion_workspace_id,
|
||||
notion_workspace_id=extract_setting.notion_info.notion_workspace_id or "",
|
||||
notion_obj_id=extract_setting.notion_info.notion_obj_id,
|
||||
notion_page_type=extract_setting.notion_info.notion_page_type,
|
||||
document_model=extract_setting.notion_info.document,
|
||||
|
||||
@@ -124,6 +124,7 @@ class ToolProviderCredentialApiEntity(BaseModel):
|
||||
default=False, description="Whether the credential is the default credential for the provider in the workspace"
|
||||
)
|
||||
credentials: Mapping[str, object] = Field(description="The credentials of the provider", default_factory=dict)
|
||||
expires_at: int = Field(default=-1, description="Unix timestamp when credential expires (-1 for no expiry)")
|
||||
|
||||
|
||||
class ToolProviderCredentialInfoApiEntity(BaseModel):
|
||||
|
||||
@@ -123,6 +123,16 @@ class ApiProviderAuthType(StrEnum):
|
||||
raise ValueError(f"invalid mode value '{value}', expected one of: {valid}")
|
||||
|
||||
|
||||
class ToolAuthType(StrEnum):
|
||||
"""
|
||||
Enum class for tool authentication type.
|
||||
Determines whether OAuth credentials are workspace-level or end-user-level.
|
||||
"""
|
||||
|
||||
WORKSPACE = "workspace"
|
||||
END_USER = "end_user"
|
||||
|
||||
|
||||
class ToolInvokeMessage(BaseModel):
|
||||
class TextMessage(BaseModel):
|
||||
text: str
|
||||
|
||||
+138
-25
@@ -49,6 +49,7 @@ from core.tools.entities.api_entities import ToolProviderApiEntity, ToolProvider
|
||||
from core.tools.entities.common_entities import I18nObject
|
||||
from core.tools.entities.tool_entities import (
|
||||
ApiProviderAuthType,
|
||||
ToolAuthType,
|
||||
ToolInvokeFrom,
|
||||
ToolParameter,
|
||||
ToolProviderType,
|
||||
@@ -58,7 +59,7 @@ from core.tools.tool_label_manager import ToolLabelManager
|
||||
from core.tools.utils.configuration import ToolParameterConfigurationManager
|
||||
from core.tools.utils.encryption import create_provider_encrypter, create_tool_provider_encrypter
|
||||
from core.tools.workflow_as_tool.tool import WorkflowTool
|
||||
from models.tools import ApiToolProvider, BuiltinToolProvider, WorkflowToolProvider
|
||||
from models.tools import ApiToolProvider, BuiltinToolProvider, EndUserAuthenticationProvider, WorkflowToolProvider
|
||||
from services.tools.tools_transform_service import ToolTransformService
|
||||
|
||||
if TYPE_CHECKING:
|
||||
@@ -78,6 +79,54 @@ class ToolManager:
|
||||
_builtin_providers_loaded = False
|
||||
_builtin_tools_labels: dict[str, Union[I18nObject, None]] = {}
|
||||
|
||||
@classmethod
|
||||
def _refresh_oauth_credentials(
|
||||
cls,
|
||||
tenant_id: str,
|
||||
provider_id: str,
|
||||
user_id: str,
|
||||
decrypted_credentials: Mapping[str, Any],
|
||||
) -> tuple[dict[str, Any], int]:
|
||||
"""
|
||||
Refresh OAuth credentials for a provider.
|
||||
|
||||
This is a helper method to centralize the OAuth token refresh logic
|
||||
used by both end-user and workspace authentication flows.
|
||||
|
||||
:param tenant_id: the tenant id
|
||||
:param provider_id: the provider id
|
||||
:param user_id: the user id (end_user_id or workspace user_id)
|
||||
:param decrypted_credentials: the current decrypted credentials
|
||||
|
||||
:return: tuple of (refreshed credentials dict, expires_at timestamp)
|
||||
"""
|
||||
from core.plugin.impl.oauth import OAuthHandler
|
||||
|
||||
# Local import to avoid circular dependency at module level
|
||||
# This import is necessary but creates a cycle: tool_manager -> builtin_tools_manage_service -> tool_manager
|
||||
# TODO: Break the circular dependency by refactoring service layer
|
||||
from services.tools.builtin_tools_manage_service import BuiltinToolManageService
|
||||
|
||||
# Parse provider ID and build OAuth configuration
|
||||
tool_provider = ToolProviderID(provider_id)
|
||||
provider_name = tool_provider.provider_name
|
||||
redirect_uri = f"{dify_config.CONSOLE_API_URL}/console/api/oauth/plugin/{provider_id}/tool/callback"
|
||||
system_credentials = BuiltinToolManageService.get_oauth_client(tenant_id, provider_id)
|
||||
|
||||
# Refresh the credentials using OAuth handler
|
||||
oauth_handler = OAuthHandler()
|
||||
refreshed_credentials = oauth_handler.refresh_credentials(
|
||||
tenant_id=tenant_id,
|
||||
user_id=user_id,
|
||||
plugin_id=tool_provider.plugin_id,
|
||||
provider=provider_name,
|
||||
redirect_uri=redirect_uri,
|
||||
system_credentials=system_credentials or {},
|
||||
credentials=decrypted_credentials,
|
||||
)
|
||||
|
||||
return refreshed_credentials.credentials, refreshed_credentials.expires_at
|
||||
|
||||
@classmethod
|
||||
def get_hardcoded_provider(cls, provider: str) -> BuiltinToolProviderController:
|
||||
"""
|
||||
@@ -165,6 +214,8 @@ class ToolManager:
|
||||
invoke_from: InvokeFrom = InvokeFrom.DEBUGGER,
|
||||
tool_invoke_from: ToolInvokeFrom = ToolInvokeFrom.AGENT,
|
||||
credential_id: str | None = None,
|
||||
auth_type: ToolAuthType = ToolAuthType.WORKSPACE,
|
||||
end_user_id: str | None = None,
|
||||
) -> Union[BuiltinTool, PluginTool, ApiTool, WorkflowTool, MCPTool]:
|
||||
"""
|
||||
get the tool runtime
|
||||
@@ -176,6 +227,8 @@ class ToolManager:
|
||||
:param invoke_from: invoke from
|
||||
:param tool_invoke_from: the tool invoke from
|
||||
:param credential_id: the credential id
|
||||
:param auth_type: the authentication type (workspace or end_user)
|
||||
:param end_user_id: the end user id (required when auth_type is END_USER)
|
||||
|
||||
:return: the tool
|
||||
"""
|
||||
@@ -200,6 +253,75 @@ class ToolManager:
|
||||
)
|
||||
),
|
||||
)
|
||||
|
||||
# Handle end-user authentication
|
||||
if auth_type == ToolAuthType.END_USER:
|
||||
if not end_user_id:
|
||||
raise ToolProviderNotFoundError("end_user_id is required for END_USER auth_type")
|
||||
|
||||
# Query end-user credentials
|
||||
enduser_provider = (
|
||||
db.session.query(EndUserAuthenticationProvider)
|
||||
.where(
|
||||
EndUserAuthenticationProvider.tenant_id == tenant_id,
|
||||
EndUserAuthenticationProvider.end_user_id == end_user_id,
|
||||
EndUserAuthenticationProvider.provider == provider_id,
|
||||
)
|
||||
.order_by(EndUserAuthenticationProvider.created_at.asc())
|
||||
.first()
|
||||
)
|
||||
|
||||
if enduser_provider is None:
|
||||
raise ToolProviderNotFoundError(
|
||||
f"No end-user credentials found for provider {provider_id}"
|
||||
)
|
||||
|
||||
# Decrypt end-user credentials
|
||||
encrypter, cache = create_provider_encrypter(
|
||||
tenant_id=tenant_id,
|
||||
config=[
|
||||
x.to_basic_provider_config()
|
||||
for x in provider_controller.get_credentials_schema_by_type(enduser_provider.credential_type)
|
||||
],
|
||||
cache=ToolProviderCredentialsCache(
|
||||
tenant_id=tenant_id, provider=provider_id, credential_id=enduser_provider.id
|
||||
),
|
||||
)
|
||||
|
||||
decrypted_credentials: Mapping[str, Any] = encrypter.decrypt(enduser_provider.credentials)
|
||||
|
||||
# Handle OAuth token refresh for end-users if expired
|
||||
if enduser_provider.expires_at != -1 and (enduser_provider.expires_at - 60) < int(time.time()):
|
||||
# Refresh credentials using the centralized helper method
|
||||
refreshed_credentials, expires_at = cls._refresh_oauth_credentials(
|
||||
tenant_id=tenant_id,
|
||||
provider_id=provider_id,
|
||||
user_id=end_user_id,
|
||||
decrypted_credentials=decrypted_credentials,
|
||||
)
|
||||
|
||||
# Update the provider with refreshed credentials
|
||||
enduser_provider.encrypted_credentials = json.dumps(encrypter.encrypt(refreshed_credentials))
|
||||
enduser_provider.expires_at = expires_at
|
||||
db.session.commit()
|
||||
decrypted_credentials = refreshed_credentials
|
||||
cache.delete()
|
||||
|
||||
return cast(
|
||||
BuiltinTool,
|
||||
builtin_tool.fork_tool_runtime(
|
||||
runtime=ToolRuntime(
|
||||
tenant_id=tenant_id,
|
||||
credentials=dict(decrypted_credentials),
|
||||
credential_type=CredentialType.of(enduser_provider.credential_type),
|
||||
runtime_parameters={},
|
||||
invoke_from=invoke_from,
|
||||
tool_invoke_from=tool_invoke_from,
|
||||
)
|
||||
),
|
||||
)
|
||||
|
||||
# Handle workspace authentication (existing logic)
|
||||
builtin_provider = None
|
||||
if isinstance(provider_controller, PluginToolProviderController):
|
||||
provider_id_entity = ToolProviderID(provider_id)
|
||||
@@ -270,34 +392,19 @@ class ToolManager:
|
||||
|
||||
# check if the credentials is expired
|
||||
if builtin_provider.expires_at != -1 and (builtin_provider.expires_at - 60) < int(time.time()):
|
||||
# TODO: circular import
|
||||
from core.plugin.impl.oauth import OAuthHandler
|
||||
from services.tools.builtin_tools_manage_service import BuiltinToolManageService
|
||||
|
||||
# refresh the credentials
|
||||
tool_provider = ToolProviderID(provider_id)
|
||||
provider_name = tool_provider.provider_name
|
||||
redirect_uri = f"{dify_config.CONSOLE_API_URL}/console/api/oauth/plugin/{provider_id}/tool/callback"
|
||||
system_credentials = BuiltinToolManageService.get_oauth_client(tenant_id, provider_id)
|
||||
|
||||
oauth_handler = OAuthHandler()
|
||||
# refresh the credentials
|
||||
refreshed_credentials = oauth_handler.refresh_credentials(
|
||||
# Refresh credentials using the centralized helper method
|
||||
refreshed_credentials, expires_at = cls._refresh_oauth_credentials(
|
||||
tenant_id=tenant_id,
|
||||
provider_id=provider_id,
|
||||
user_id=builtin_provider.user_id,
|
||||
plugin_id=tool_provider.plugin_id,
|
||||
provider=provider_name,
|
||||
redirect_uri=redirect_uri,
|
||||
system_credentials=system_credentials or {},
|
||||
credentials=decrypted_credentials,
|
||||
decrypted_credentials=decrypted_credentials,
|
||||
)
|
||||
# update the credentials
|
||||
builtin_provider.encrypted_credentials = json.dumps(
|
||||
encrypter.encrypt(refreshed_credentials.credentials)
|
||||
)
|
||||
builtin_provider.expires_at = refreshed_credentials.expires_at
|
||||
|
||||
# Update the provider with refreshed credentials
|
||||
builtin_provider.encrypted_credentials = json.dumps(encrypter.encrypt(refreshed_credentials))
|
||||
builtin_provider.expires_at = expires_at
|
||||
db.session.commit()
|
||||
decrypted_credentials = refreshed_credentials.credentials
|
||||
decrypted_credentials = refreshed_credentials
|
||||
cache.delete()
|
||||
|
||||
return cast(
|
||||
@@ -368,6 +475,7 @@ class ToolManager:
|
||||
agent_tool: AgentToolEntity,
|
||||
invoke_from: InvokeFrom = InvokeFrom.DEBUGGER,
|
||||
variable_pool: Optional["VariablePool"] = None,
|
||||
end_user_id: str | None = None,
|
||||
) -> Tool:
|
||||
"""
|
||||
get the agent tool runtime
|
||||
@@ -380,6 +488,8 @@ class ToolManager:
|
||||
invoke_from=invoke_from,
|
||||
tool_invoke_from=ToolInvokeFrom.AGENT,
|
||||
credential_id=agent_tool.credential_id,
|
||||
auth_type=agent_tool.auth_type,
|
||||
end_user_id=end_user_id,
|
||||
)
|
||||
runtime_parameters = {}
|
||||
parameters = tool_entity.get_merged_runtime_parameters()
|
||||
@@ -410,6 +520,7 @@ class ToolManager:
|
||||
workflow_tool: "ToolEntity",
|
||||
invoke_from: InvokeFrom = InvokeFrom.DEBUGGER,
|
||||
variable_pool: Optional["VariablePool"] = None,
|
||||
end_user_id: str | None = None,
|
||||
) -> Tool:
|
||||
"""
|
||||
get the workflow tool runtime
|
||||
@@ -423,6 +534,8 @@ class ToolManager:
|
||||
invoke_from=invoke_from,
|
||||
tool_invoke_from=ToolInvokeFrom.WORKFLOW,
|
||||
credential_id=workflow_tool.credential_id,
|
||||
auth_type=workflow_tool.auth_type,
|
||||
end_user_id=end_user_id,
|
||||
)
|
||||
|
||||
parameters = tool_runtime.get_merged_runtime_parameters()
|
||||
|
||||
@@ -3,7 +3,7 @@ from typing import Any, Literal, Union
|
||||
from pydantic import BaseModel, field_validator
|
||||
from pydantic_core.core_schema import ValidationInfo
|
||||
|
||||
from core.tools.entities.tool_entities import ToolProviderType
|
||||
from core.tools.entities.tool_entities import ToolAuthType, ToolProviderType
|
||||
from core.workflow.nodes.base.entities import BaseNodeData
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ class ToolEntity(BaseModel):
|
||||
tool_configurations: dict[str, Any]
|
||||
credential_id: str | None = None
|
||||
plugin_unique_identifier: str | None = None # redundancy
|
||||
auth_type: ToolAuthType = ToolAuthType.WORKSPACE # OAuth authentication level
|
||||
|
||||
@field_validator("tool_configurations", mode="before")
|
||||
@classmethod
|
||||
|
||||
@@ -66,6 +66,7 @@ class ToolNode(Node[ToolNodeData]):
|
||||
# get tool runtime
|
||||
try:
|
||||
from core.tools.tool_manager import ToolManager
|
||||
from models.enums import UserFrom
|
||||
|
||||
# This is an issue that caused problems before.
|
||||
# Logically, we shouldn't use the node_data.version field for judgment
|
||||
@@ -74,8 +75,20 @@ class ToolNode(Node[ToolNodeData]):
|
||||
variable_pool: VariablePool | None = None
|
||||
if self.node_data.version != "1" or self.node_data.tool_node_version is not None:
|
||||
variable_pool = self.graph_runtime_state.variable_pool
|
||||
|
||||
# Determine end_user_id based on user_from
|
||||
end_user_id = None
|
||||
if self.user_from == UserFrom.END_USER:
|
||||
end_user_id = self.user_id
|
||||
|
||||
tool_runtime = ToolManager.get_workflow_tool_runtime(
|
||||
self.tenant_id, self.app_id, self._node_id, self.node_data, self.invoke_from, variable_pool
|
||||
self.tenant_id,
|
||||
self.app_id,
|
||||
self._node_id,
|
||||
self.node_data,
|
||||
self.invoke_from,
|
||||
variable_pool,
|
||||
end_user_id,
|
||||
)
|
||||
except ToolNodeError as e:
|
||||
yield StreamCompletedEvent(
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
"""
|
||||
Field Encoding/Decoding Utilities
|
||||
|
||||
Provides Base64 decoding for sensitive fields (password, verification code)
|
||||
received from the frontend.
|
||||
|
||||
Note: This uses Base64 encoding for obfuscation, not cryptographic encryption.
|
||||
Real security relies on HTTPS for transport layer encryption.
|
||||
"""
|
||||
|
||||
import base64
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class FieldEncryption:
|
||||
"""Handle decoding of sensitive fields during transmission"""
|
||||
|
||||
@classmethod
|
||||
def decrypt_field(cls, encoded_text: str) -> str | None:
|
||||
"""
|
||||
Decode Base64 encoded field from frontend.
|
||||
|
||||
Args:
|
||||
encoded_text: Base64 encoded text from frontend
|
||||
|
||||
Returns:
|
||||
Decoded plaintext, or None if decoding fails
|
||||
"""
|
||||
try:
|
||||
# Decode base64
|
||||
decoded_bytes = base64.b64decode(encoded_text)
|
||||
decoded_text = decoded_bytes.decode("utf-8")
|
||||
logger.debug("Field decoding successful")
|
||||
return decoded_text
|
||||
|
||||
except Exception:
|
||||
# Decoding failed - return None to trigger error in caller
|
||||
return None
|
||||
|
||||
@classmethod
|
||||
def decrypt_password(cls, encrypted_password: str) -> str | None:
|
||||
"""
|
||||
Decrypt password field
|
||||
|
||||
Args:
|
||||
encrypted_password: Encrypted password from frontend
|
||||
|
||||
Returns:
|
||||
Decrypted password or None if decryption fails
|
||||
"""
|
||||
return cls.decrypt_field(encrypted_password)
|
||||
|
||||
@classmethod
|
||||
def decrypt_verification_code(cls, encrypted_code: str) -> str | None:
|
||||
"""
|
||||
Decrypt verification code field
|
||||
|
||||
Args:
|
||||
encrypted_code: Encrypted code from frontend
|
||||
|
||||
Returns:
|
||||
Decrypted code or None if decryption fails
|
||||
"""
|
||||
return cls.decrypt_field(encrypted_code)
|
||||
+71
@@ -0,0 +1,71 @@
|
||||
"""add enduser authentication provider
|
||||
|
||||
Revision ID: a7b4e8f2c9d1
|
||||
Revises: fecff1c3da27
|
||||
Create Date: 2025-11-18 14:00:00.000000
|
||||
|
||||
"""
|
||||
import models as models
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = "a7b4e8f2c9d1"
|
||||
down_revision = "fecff1c3da27"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.create_table(
|
||||
"tool_enduser_authentication_providers",
|
||||
sa.Column(
|
||||
"id",
|
||||
models.types.StringUUID(),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column(
|
||||
"name",
|
||||
sa.String(length=256),
|
||||
server_default="API KEY 1",
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column("tenant_id", models.types.StringUUID(), nullable=False),
|
||||
sa.Column("end_user_id", models.types.StringUUID(), nullable=False),
|
||||
sa.Column("provider", sa.Text(), nullable=False),
|
||||
sa.Column("encrypted_credentials", sa.Text(), default="", nullable=False),
|
||||
sa.Column(
|
||||
"created_at",
|
||||
sa.DateTime(),
|
||||
server_default=sa.func.current_timestamp(),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column(
|
||||
"updated_at",
|
||||
sa.DateTime(),
|
||||
server_default=sa.func.current_timestamp(),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column(
|
||||
"credential_type",
|
||||
sa.String(length=32),
|
||||
server_default="api-key",
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column("expires_at", sa.BigInteger(), server_default=sa.text("-1"), nullable=False),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
sa.UniqueConstraint(
|
||||
"end_user_id",
|
||||
"provider",
|
||||
),
|
||||
)
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade():
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.drop_table("tool_enduser_authentication_providers")
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
"""merge_heads
|
||||
|
||||
Revision ID: 3134f4e0620d
|
||||
Revises: d57accd375ae, a7b4e8f2c9d1
|
||||
Create Date: 2025-12-14 14:18:19.393720
|
||||
|
||||
"""
|
||||
from alembic import op
|
||||
import models as models
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = '3134f4e0620d'
|
||||
down_revision = ('d57accd375ae', 'a7b4e8f2c9d1')
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
pass
|
||||
|
||||
|
||||
def downgrade():
|
||||
pass
|
||||
+31
@@ -0,0 +1,31 @@
|
||||
"""add type column not null default tool
|
||||
|
||||
Revision ID: 03ea244985ce
|
||||
Revises: d57accd375ae
|
||||
Create Date: 2025-12-16 18:17:12.193877
|
||||
|
||||
"""
|
||||
from alembic import op
|
||||
import models as models
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects import postgresql
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = '03ea244985ce'
|
||||
down_revision = 'd57accd375ae'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
with op.batch_alter_table('pipeline_recommended_plugins', schema=None) as batch_op:
|
||||
batch_op.add_column(sa.Column('type', sa.String(length=50), server_default=sa.text("'tool'"), nullable=False))
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade():
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
with op.batch_alter_table('pipeline_recommended_plugins', schema=None) as batch_op:
|
||||
batch_op.drop_column('type')
|
||||
# ### end Alembic commands ###
|
||||
@@ -80,6 +80,7 @@ from .task import CeleryTask, CeleryTaskSet
|
||||
from .tools import (
|
||||
ApiToolProvider,
|
||||
BuiltinToolProvider,
|
||||
EndUserAuthenticationProvider,
|
||||
ToolConversationVariables,
|
||||
ToolFile,
|
||||
ToolLabelBinding,
|
||||
@@ -149,6 +150,7 @@ __all__ = [
|
||||
"DocumentSegment",
|
||||
"Embedding",
|
||||
"EndUser",
|
||||
"EndUserAuthenticationProvider",
|
||||
"ExternalKnowledgeApis",
|
||||
"ExternalKnowledgeBindings",
|
||||
"IconType",
|
||||
|
||||
@@ -1532,6 +1532,7 @@ class PipelineRecommendedPlugin(TypeBase):
|
||||
)
|
||||
plugin_id: Mapped[str] = mapped_column(LongText, nullable=False)
|
||||
provider_name: Mapped[str] = mapped_column(LongText, nullable=False)
|
||||
type: Mapped[str] = mapped_column(sa.String(50), nullable=False, server_default=sa.text("'tool'"))
|
||||
position: Mapped[int] = mapped_column(sa.Integer, nullable=False, default=0)
|
||||
active: Mapped[bool] = mapped_column(sa.Boolean, nullable=False, default=True)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
|
||||
@@ -9,9 +9,11 @@ from deprecated import deprecated
|
||||
from sqlalchemy import ForeignKey, String, func
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from core.plugin.entities.plugin_daemon import CredentialType
|
||||
from core.tools.entities.common_entities import I18nObject
|
||||
from core.tools.entities.tool_bundle import ApiToolBundle
|
||||
from core.tools.entities.tool_entities import ApiProviderSchemaType, WorkflowToolParameterConfiguration
|
||||
from libs.uuid_utils import uuidv7
|
||||
|
||||
from .base import TypeBase
|
||||
from .engine import db
|
||||
@@ -115,6 +117,59 @@ class BuiltinToolProvider(TypeBase):
|
||||
return cast(dict[str, Any], json.loads(self.encrypted_credentials))
|
||||
|
||||
|
||||
class EndUserAuthenticationProvider(TypeBase):
|
||||
"""
|
||||
This table stores the authentication credentials for end users in tools.
|
||||
Mimics the BuiltinToolProvider structure but for end users instead of tenants.
|
||||
"""
|
||||
|
||||
__tablename__ = "tool_enduser_authentication_providers"
|
||||
__table_args__ = (
|
||||
sa.UniqueConstraint("end_user_id", "provider"),
|
||||
)
|
||||
|
||||
# id of the authentication provider
|
||||
id: Mapped[str] = mapped_column(StringUUID, primary_key=True, default=lambda: str(uuidv7()), init=False)
|
||||
# id of the tenant
|
||||
tenant_id: Mapped[str] = mapped_column(StringUUID, nullable=False)
|
||||
# id of the end user
|
||||
end_user_id: Mapped[str] = mapped_column(StringUUID, nullable=False)
|
||||
# name of the tool provider
|
||||
provider: Mapped[str] = mapped_column(LongText, nullable=False)
|
||||
name: Mapped[str] = mapped_column(
|
||||
String(256),
|
||||
nullable=False,
|
||||
default="API KEY 1",
|
||||
)
|
||||
# encrypted credentials for the end user
|
||||
encrypted_credentials: Mapped[str] = mapped_column(LongText, nullable=False, default="")
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
sa.DateTime, nullable=False, server_default=func.current_timestamp(), init=False
|
||||
)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
sa.DateTime,
|
||||
nullable=False,
|
||||
server_default=func.current_timestamp(),
|
||||
onupdate=func.current_timestamp(),
|
||||
init=False,
|
||||
)
|
||||
# credential type, e.g., "api-key", "oauth2"
|
||||
credential_type: Mapped[CredentialType] = mapped_column(
|
||||
String(32), nullable=False, default=CredentialType.API_KEY
|
||||
)
|
||||
# Unix timestamp in seconds since epoch (1970-01-01 UTC); -1 indicates no expiration
|
||||
expires_at: Mapped[int] = mapped_column(sa.BigInteger, nullable=False, default=-1)
|
||||
|
||||
@property
|
||||
def credentials(self) -> dict[str, Any]:
|
||||
if not self.encrypted_credentials:
|
||||
return {}
|
||||
try:
|
||||
return cast(dict[str, Any], json.loads(self.encrypted_credentials))
|
||||
except json.JSONDecodeError:
|
||||
return {}
|
||||
|
||||
|
||||
class ApiToolProvider(TypeBase):
|
||||
"""
|
||||
The table stores the api providers.
|
||||
|
||||
@@ -1248,14 +1248,13 @@ class RagPipelineService:
|
||||
session.commit()
|
||||
return workflow_node_execution_db_model
|
||||
|
||||
def get_recommended_plugins(self) -> dict:
|
||||
def get_recommended_plugins(self, type: str) -> dict:
|
||||
# Query active recommended plugins
|
||||
pipeline_recommended_plugins = (
|
||||
db.session.query(PipelineRecommendedPlugin)
|
||||
.where(PipelineRecommendedPlugin.active == True)
|
||||
.order_by(PipelineRecommendedPlugin.position.asc())
|
||||
.all()
|
||||
)
|
||||
query = db.session.query(PipelineRecommendedPlugin).where(PipelineRecommendedPlugin.active == True)
|
||||
if type and type != "all":
|
||||
query = query.where(PipelineRecommendedPlugin.type == type)
|
||||
|
||||
pipeline_recommended_plugins = query.order_by(PipelineRecommendedPlugin.position.asc()).all()
|
||||
|
||||
if not pipeline_recommended_plugins:
|
||||
return {
|
||||
|
||||
@@ -0,0 +1,227 @@
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from core.tools.tool_manager import ToolManager
|
||||
from extensions.ext_database import db
|
||||
from models.workflow import Workflow
|
||||
from services.tools.enduser_auth_service import EndUserAuthService
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class AppAuthRequirementService:
|
||||
"""
|
||||
Service for analyzing authentication requirements in apps.
|
||||
Examines workflow DSL to identify which providers need end-user authentication.
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def get_tool_auth_requirements(
|
||||
app_id: str,
|
||||
tenant_id: str,
|
||||
provider_type: str | None = None,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""
|
||||
Get all authentication requirements for tools in an app.
|
||||
|
||||
:param app_id: The application ID
|
||||
:param tenant_id: The tenant ID
|
||||
:param provider_type: Optional filter by provider type (e.g., "tool")
|
||||
:return: List of provider requirements
|
||||
"""
|
||||
try:
|
||||
# Get latest published workflow for the app
|
||||
with Session(db.engine, autoflush=False) as session:
|
||||
workflow = (
|
||||
session.query(Workflow)
|
||||
.filter_by(app_id=app_id, tenant_id=tenant_id)
|
||||
.order_by(Workflow.created_at.desc())
|
||||
.first()
|
||||
)
|
||||
|
||||
if not workflow:
|
||||
return []
|
||||
|
||||
# Parse workflow graph to find tool nodes
|
||||
graph = workflow.graph_dict
|
||||
if not graph or "nodes" not in graph:
|
||||
return []
|
||||
|
||||
providers = []
|
||||
seen_providers = set()
|
||||
|
||||
# Iterate through workflow nodes
|
||||
for node in graph.get("nodes", []):
|
||||
node_data = node.get("data", {})
|
||||
node_type = node_data.get("type")
|
||||
|
||||
# Check if it's a tool node
|
||||
if node_type == "tool":
|
||||
provider_id = node_data.get("provider_id")
|
||||
provider_name = node_data.get("provider_name")
|
||||
tool_name = node_data.get("tool_name")
|
||||
|
||||
if not provider_id:
|
||||
continue
|
||||
|
||||
# Avoid duplicates
|
||||
if provider_id in seen_providers:
|
||||
continue
|
||||
|
||||
seen_providers.add(provider_id)
|
||||
|
||||
# Get provider controller to check authentication requirements
|
||||
try:
|
||||
provider_controller = ToolManager.get_builtin_provider(provider_id, tenant_id)
|
||||
|
||||
# Check if provider needs credentials
|
||||
if not provider_controller.need_credentials:
|
||||
continue
|
||||
|
||||
# Get supported credential types
|
||||
supported_types = provider_controller.get_supported_credential_types()
|
||||
|
||||
# Determine required credential type (prefer OAuth if supported)
|
||||
required_type = None
|
||||
if supported_types:
|
||||
# Prefer OAuth2, then API key
|
||||
from core.plugin.entities.plugin_daemon import CredentialType
|
||||
|
||||
if CredentialType.OAUTH2 in supported_types:
|
||||
required_type = "oauth2"
|
||||
elif CredentialType.API_KEY in supported_types:
|
||||
required_type = "api-key"
|
||||
else:
|
||||
required_type = supported_types[0].value
|
||||
|
||||
providers.append(
|
||||
{
|
||||
"provider_id": provider_id,
|
||||
"provider_name": provider_name or provider_id,
|
||||
"supported_credential_types": [ct.value for ct in supported_types],
|
||||
"required_credential_type": required_type,
|
||||
"provider_type": "tool",
|
||||
"feature_context": {
|
||||
"node_ids": [node.get("id")],
|
||||
"tool_names": [tool_name] if tool_name else [],
|
||||
},
|
||||
}
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning("Error getting provider info for %s: %s", provider_id, e)
|
||||
continue
|
||||
|
||||
# Filter by provider_type if specified
|
||||
if provider_type:
|
||||
providers = [p for p in providers if p.get("provider_type") == provider_type]
|
||||
|
||||
return providers
|
||||
except Exception:
|
||||
logger.exception("Error getting auth requirements for app %s", app_id)
|
||||
return []
|
||||
|
||||
@staticmethod
|
||||
def get_required_providers(
|
||||
tenant_id: str,
|
||||
app_id: str,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""
|
||||
Get list of providers that require end-user authentication for an app.
|
||||
Simplified version of get_tool_auth_requirements for API use.
|
||||
|
||||
:param tenant_id: The tenant ID
|
||||
:param app_id: The application ID
|
||||
:return: List of provider information dictionaries
|
||||
"""
|
||||
requirements = AppAuthRequirementService.get_tool_auth_requirements(
|
||||
app_id=app_id,
|
||||
tenant_id=tenant_id,
|
||||
)
|
||||
|
||||
# Transform to simpler format for API response
|
||||
return [
|
||||
{
|
||||
"provider_id": req["provider_id"],
|
||||
"provider_name": req["provider_name"],
|
||||
"credential_type": req["required_credential_type"],
|
||||
"is_required": True,
|
||||
"oauth_config": None if req["required_credential_type"] != "oauth2" else {
|
||||
"supported_types": req["supported_credential_types"],
|
||||
},
|
||||
}
|
||||
for req in requirements
|
||||
]
|
||||
|
||||
@staticmethod
|
||||
def get_auth_status(
|
||||
app_id: str,
|
||||
end_user_id: str,
|
||||
tenant_id: str,
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
Get overall authentication status for an app and end user.
|
||||
Shows which providers are authenticated and which need authentication.
|
||||
|
||||
:param app_id: The application ID
|
||||
:param end_user_id: The end user ID
|
||||
:param tenant_id: The tenant ID
|
||||
:return: Dict with authentication status for all providers
|
||||
"""
|
||||
try:
|
||||
# Get required providers for this app
|
||||
required_providers = AppAuthRequirementService.get_tool_auth_requirements(app_id, tenant_id)
|
||||
|
||||
# Check authentication status for each provider
|
||||
provider_statuses = []
|
||||
for provider_info in required_providers:
|
||||
provider_id = provider_info["provider_id"]
|
||||
|
||||
# Get credentials for this provider
|
||||
credentials = EndUserAuthService.list_credentials(tenant_id, end_user_id, provider_id)
|
||||
|
||||
# Build status
|
||||
provider_status = {
|
||||
"provider_id": provider_id,
|
||||
"provider_name": provider_info["provider_name"],
|
||||
"provider_type": provider_info["provider_type"],
|
||||
"authenticated": len(credentials) > 0,
|
||||
"credentials": [
|
||||
{
|
||||
"credential_id": cred.id,
|
||||
"name": cred.name,
|
||||
"type": cred.credential_type.value,
|
||||
"is_default": cred.is_default,
|
||||
"expires_at": cred.expires_at,
|
||||
}
|
||||
for cred in credentials
|
||||
],
|
||||
}
|
||||
|
||||
provider_statuses.append(provider_status)
|
||||
|
||||
return {"providers": provider_statuses}
|
||||
except Exception:
|
||||
logger.exception("Error getting auth status for app %s", app_id)
|
||||
return {"providers": []}
|
||||
|
||||
@staticmethod
|
||||
def is_provider_authenticated(
|
||||
provider_id: str,
|
||||
end_user_id: str,
|
||||
tenant_id: str,
|
||||
) -> bool:
|
||||
"""
|
||||
Check if a specific provider is authenticated for an end user.
|
||||
|
||||
:param provider_id: The provider identifier
|
||||
:param end_user_id: The end user ID
|
||||
:param tenant_id: The tenant ID
|
||||
:return: True if authenticated, False otherwise
|
||||
"""
|
||||
try:
|
||||
credentials = EndUserAuthService.list_credentials(tenant_id, end_user_id, provider_id)
|
||||
return len(credentials) > 0
|
||||
except Exception:
|
||||
return False
|
||||
@@ -0,0 +1,558 @@
|
||||
import json
|
||||
import logging
|
||||
|
||||
from sqlalchemy import exists, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from constants import HIDDEN_VALUE, UNKNOWN_VALUE
|
||||
from core.helper.name_generator import generate_incremental_name
|
||||
from core.helper.provider_cache import NoOpProviderCredentialCache
|
||||
from core.plugin.entities.plugin_daemon import CredentialType
|
||||
from core.tools.entities.api_entities import ToolProviderCredentialApiEntity
|
||||
from core.tools.tool_manager import ToolManager
|
||||
from core.tools.utils.encryption import create_provider_encrypter
|
||||
from extensions.ext_database import db
|
||||
from extensions.ext_redis import redis_client
|
||||
from models.tools import EndUserAuthenticationProvider
|
||||
from services.tools.tools_transform_service import ToolTransformService
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class EndUserAuthService:
|
||||
"""
|
||||
Service for managing end-user authentication credentials.
|
||||
Follows similar patterns to BuiltinToolManageService but for end users.
|
||||
"""
|
||||
|
||||
__MAX_CREDENTIALS_PER_PROVIDER__ = 100
|
||||
|
||||
@staticmethod
|
||||
def list_credentials(
|
||||
tenant_id: str, end_user_id: str, provider_id: str
|
||||
) -> list[ToolProviderCredentialApiEntity]:
|
||||
"""
|
||||
List all credentials for a specific provider and end user.
|
||||
|
||||
:param end_user_id: The end user ID
|
||||
:param tenant_id: The tenant ID
|
||||
:param provider_id: The provider identifier
|
||||
:return: List of credential entities
|
||||
"""
|
||||
with Session(db.engine, autoflush=False) as session:
|
||||
credentials = (
|
||||
session.query(EndUserAuthenticationProvider)
|
||||
.filter_by(end_user_id=end_user_id, tenant_id=tenant_id, provider=provider_id)
|
||||
.order_by(EndUserAuthenticationProvider.created_at.asc())
|
||||
.all()
|
||||
)
|
||||
|
||||
if not credentials:
|
||||
return []
|
||||
|
||||
# Get provider controller to access credential schema
|
||||
provider_controller = ToolManager.get_builtin_provider(provider_id, tenant_id)
|
||||
|
||||
result: list[ToolProviderCredentialApiEntity] = []
|
||||
for credential in credentials:
|
||||
try:
|
||||
# Create encrypter for masking credentials
|
||||
encrypter, _ = EndUserAuthService._create_encrypter(
|
||||
tenant_id, provider_controller, credential.credential_type
|
||||
)
|
||||
|
||||
# Decrypt and mask credentials
|
||||
decrypted = encrypter.decrypt(credential.credentials)
|
||||
masked_credentials = encrypter.mask_plugin_credentials(decrypted)
|
||||
|
||||
# Convert to API entity
|
||||
credential_entity = ToolTransformService.convert_enduser_provider_to_credential_entity(
|
||||
provider=credential,
|
||||
credentials=dict(masked_credentials),
|
||||
)
|
||||
result.append(credential_entity)
|
||||
except Exception:
|
||||
logger.exception("Error processing credential %s", credential.id)
|
||||
continue
|
||||
|
||||
return result
|
||||
|
||||
@staticmethod
|
||||
def get_credential(
|
||||
credential_id: str, end_user_id: str, tenant_id: str, mask_credentials: bool = True
|
||||
) -> ToolProviderCredentialApiEntity | None:
|
||||
"""
|
||||
Get a specific credential by ID.
|
||||
|
||||
:param credential_id: The credential ID
|
||||
:param end_user_id: The end user ID
|
||||
:param tenant_id: The tenant ID
|
||||
:param mask_credentials: Whether to mask secret fields
|
||||
:return: Credential entity or None
|
||||
"""
|
||||
with Session(db.engine, autoflush=False) as session:
|
||||
credential = (
|
||||
session.query(EndUserAuthenticationProvider)
|
||||
.filter_by(id=credential_id, end_user_id=end_user_id, tenant_id=tenant_id)
|
||||
.first()
|
||||
)
|
||||
|
||||
if not credential:
|
||||
return None
|
||||
|
||||
# Get provider controller
|
||||
provider_controller = ToolManager.get_builtin_provider(credential.provider, tenant_id)
|
||||
|
||||
# Create encrypter
|
||||
encrypter, _ = EndUserAuthService._create_encrypter(
|
||||
tenant_id, provider_controller, credential.credential_type
|
||||
)
|
||||
|
||||
# Decrypt credentials
|
||||
decrypted = encrypter.decrypt(credential.credentials)
|
||||
|
||||
# Mask if requested
|
||||
if mask_credentials:
|
||||
decrypted = encrypter.mask_plugin_credentials(decrypted)
|
||||
|
||||
# Convert to API entity
|
||||
return ToolTransformService.convert_enduser_provider_to_credential_entity(
|
||||
provider=credential,
|
||||
credentials=dict(decrypted),
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def create_api_key_credential(
|
||||
tenant_id: str,
|
||||
end_user_id: str,
|
||||
provider_id: str,
|
||||
credentials: dict,
|
||||
name: str | None = None,
|
||||
) -> ToolProviderCredentialApiEntity:
|
||||
"""
|
||||
Create a new API key credential for an end user.
|
||||
|
||||
:param tenant_id: The tenant ID
|
||||
:param end_user_id: The end user ID
|
||||
:param provider_id: The provider identifier
|
||||
:param credentials: The credential data
|
||||
:param name: Optional custom name
|
||||
:return: Created credential entity
|
||||
"""
|
||||
with Session(db.engine) as session:
|
||||
try:
|
||||
lock = f"enduser_credential_create_lock:{end_user_id}_{provider_id}"
|
||||
with redis_client.lock(lock, timeout=20):
|
||||
# Get provider controller
|
||||
provider_controller = ToolManager.get_builtin_provider(provider_id, tenant_id)
|
||||
if not provider_controller.need_credentials:
|
||||
raise ValueError(f"Provider {provider_id} does not need credentials")
|
||||
|
||||
# Check credential count
|
||||
credential_count = (
|
||||
session.query(EndUserAuthenticationProvider)
|
||||
.filter_by(end_user_id=end_user_id, tenant_id=tenant_id, provider=provider_id)
|
||||
.count()
|
||||
)
|
||||
|
||||
if credential_count >= EndUserAuthService.__MAX_CREDENTIALS_PER_PROVIDER__:
|
||||
raise ValueError(
|
||||
f"Maximum number of credentials ({EndUserAuthService.__MAX_CREDENTIALS_PER_PROVIDER__}) "
|
||||
f"reached for provider {provider_id}"
|
||||
)
|
||||
|
||||
# Validate credentials
|
||||
credential_type = CredentialType.API_KEY
|
||||
if CredentialType.of(credential_type).is_validate_allowed():
|
||||
provider_controller.validate_credentials(end_user_id, credentials)
|
||||
|
||||
# Generate name if not provided
|
||||
if name is None or name == "":
|
||||
name = EndUserAuthService._generate_credential_name(
|
||||
session=session,
|
||||
end_user_id=end_user_id,
|
||||
tenant_id=tenant_id,
|
||||
provider=provider_id,
|
||||
credential_type=credential_type,
|
||||
)
|
||||
else:
|
||||
# Validate name length
|
||||
if len(name) > 30:
|
||||
raise ValueError("Credential name must be 30 characters or less")
|
||||
|
||||
# Check if name is already used
|
||||
if session.scalar(
|
||||
select(
|
||||
exists().where(
|
||||
EndUserAuthenticationProvider.end_user_id == end_user_id,
|
||||
EndUserAuthenticationProvider.tenant_id == tenant_id,
|
||||
EndUserAuthenticationProvider.provider == provider_id,
|
||||
EndUserAuthenticationProvider.name == name,
|
||||
)
|
||||
)
|
||||
):
|
||||
raise ValueError(f"The credential name '{name}' is already used")
|
||||
|
||||
# Create encrypter
|
||||
encrypter, _ = EndUserAuthService._create_encrypter(
|
||||
tenant_id, provider_controller, credential_type
|
||||
)
|
||||
|
||||
# Create credential record
|
||||
db_credential = EndUserAuthenticationProvider(
|
||||
tenant_id=tenant_id,
|
||||
end_user_id=end_user_id,
|
||||
provider=provider_id,
|
||||
encrypted_credentials=json.dumps(encrypter.encrypt(credentials)),
|
||||
credential_type=credential_type,
|
||||
name=name,
|
||||
expires_at=-1, # API keys don't expire
|
||||
)
|
||||
|
||||
session.add(db_credential)
|
||||
session.commit()
|
||||
session.refresh(db_credential)
|
||||
|
||||
# Return masked credentials
|
||||
masked_credentials = encrypter.mask_plugin_credentials(credentials)
|
||||
return ToolTransformService.convert_enduser_provider_to_credential_entity(
|
||||
provider=db_credential,
|
||||
credentials=dict(masked_credentials),
|
||||
)
|
||||
except Exception as e:
|
||||
session.rollback()
|
||||
logger.exception("Error creating API key credential")
|
||||
raise ValueError(str(e))
|
||||
|
||||
@staticmethod
|
||||
def create_oauth_credential(
|
||||
end_user_id: str,
|
||||
tenant_id: str,
|
||||
provider: str,
|
||||
credentials: dict,
|
||||
expires_at: int = -1,
|
||||
name: str | None = None,
|
||||
) -> EndUserAuthenticationProvider:
|
||||
"""
|
||||
Create a new OAuth credential for an end user.
|
||||
Used internally by OAuth callback handler.
|
||||
|
||||
:param end_user_id: The end user ID
|
||||
:param tenant_id: The tenant ID
|
||||
:param provider: The provider identifier
|
||||
:param credentials: The OAuth credentials (access_token, refresh_token, etc.)
|
||||
:param expires_at: Unix timestamp when token expires (-1 for no expiry)
|
||||
:param name: Optional custom name
|
||||
:return: Created credential record
|
||||
"""
|
||||
with Session(db.engine) as session:
|
||||
try:
|
||||
lock = f"enduser_credential_create_lock:{end_user_id}_{provider}"
|
||||
with redis_client.lock(lock, timeout=20):
|
||||
# Get provider controller
|
||||
provider_controller = ToolManager.get_builtin_provider(provider, tenant_id)
|
||||
|
||||
# Check credential count
|
||||
credential_count = (
|
||||
session.query(EndUserAuthenticationProvider)
|
||||
.filter_by(end_user_id=end_user_id, tenant_id=tenant_id, provider=provider)
|
||||
.count()
|
||||
)
|
||||
|
||||
if credential_count >= EndUserAuthService.__MAX_CREDENTIALS_PER_PROVIDER__:
|
||||
raise ValueError(
|
||||
f"Maximum number of credentials ({EndUserAuthService.__MAX_CREDENTIALS_PER_PROVIDER__}) "
|
||||
f"reached for provider {provider}"
|
||||
)
|
||||
|
||||
# Generate name if not provided
|
||||
credential_type = CredentialType.OAUTH2
|
||||
if name is None or name == "":
|
||||
name = EndUserAuthService._generate_credential_name(
|
||||
session=session,
|
||||
end_user_id=end_user_id,
|
||||
tenant_id=tenant_id,
|
||||
provider=provider,
|
||||
credential_type=credential_type,
|
||||
)
|
||||
|
||||
# Create encrypter
|
||||
encrypter, _ = EndUserAuthService._create_encrypter(
|
||||
tenant_id, provider_controller, credential_type
|
||||
)
|
||||
|
||||
# Create credential record
|
||||
db_credential = EndUserAuthenticationProvider(
|
||||
tenant_id=tenant_id,
|
||||
end_user_id=end_user_id,
|
||||
provider=provider,
|
||||
encrypted_credentials=json.dumps(encrypter.encrypt(credentials)),
|
||||
credential_type=credential_type,
|
||||
name=name,
|
||||
expires_at=expires_at,
|
||||
)
|
||||
|
||||
session.add(db_credential)
|
||||
session.commit()
|
||||
session.refresh(db_credential)
|
||||
|
||||
return db_credential
|
||||
except Exception as e:
|
||||
session.rollback()
|
||||
logger.exception("Error creating OAuth credential")
|
||||
raise ValueError(str(e))
|
||||
|
||||
@staticmethod
|
||||
def update_credential(
|
||||
credential_id: str,
|
||||
end_user_id: str,
|
||||
tenant_id: str,
|
||||
credentials: dict | None = None,
|
||||
name: str | None = None,
|
||||
) -> ToolProviderCredentialApiEntity:
|
||||
"""
|
||||
Update an existing credential (API key only).
|
||||
|
||||
:param credential_id: The credential ID
|
||||
:param end_user_id: The end user ID
|
||||
:param tenant_id: The tenant ID
|
||||
:param credentials: Updated credentials (optional)
|
||||
:param name: Updated name (optional)
|
||||
:return: Updated credential entity
|
||||
"""
|
||||
with Session(db.engine) as session:
|
||||
try:
|
||||
# Get credential
|
||||
db_credential = (
|
||||
session.query(EndUserAuthenticationProvider)
|
||||
.filter_by(id=credential_id, end_user_id=end_user_id, tenant_id=tenant_id)
|
||||
.first()
|
||||
)
|
||||
|
||||
if not db_credential:
|
||||
raise ValueError(f"Credential {credential_id} not found")
|
||||
|
||||
# Only API key credentials can be updated
|
||||
if not CredentialType.of(db_credential.credential_type).is_editable():
|
||||
raise ValueError("Only API key credentials can be updated via this endpoint")
|
||||
|
||||
# At least one field must be provided
|
||||
if credentials is None and name is None:
|
||||
raise ValueError("At least one field (credentials or name) must be provided")
|
||||
|
||||
# Get provider controller
|
||||
provider_controller = ToolManager.get_builtin_provider(db_credential.provider, tenant_id)
|
||||
|
||||
# Create encrypter
|
||||
encrypter, _ = EndUserAuthService._create_encrypter(
|
||||
tenant_id, provider_controller, db_credential.credential_type
|
||||
)
|
||||
|
||||
# Update credentials if provided
|
||||
if credentials:
|
||||
# Decrypt original credentials
|
||||
original_credentials = encrypter.decrypt(db_credential.credentials)
|
||||
|
||||
# Merge with new credentials, keeping hidden values
|
||||
new_credentials: dict = {
|
||||
key: value if value != HIDDEN_VALUE else original_credentials.get(key, UNKNOWN_VALUE)
|
||||
for key, value in credentials.items()
|
||||
}
|
||||
|
||||
# Validate new credentials
|
||||
if CredentialType.of(db_credential.credential_type).is_validate_allowed():
|
||||
provider_controller.validate_credentials(end_user_id, new_credentials)
|
||||
|
||||
# Encrypt and save
|
||||
db_credential.encrypted_credentials = json.dumps(encrypter.encrypt(new_credentials))
|
||||
|
||||
# Update name if provided
|
||||
if name and name != db_credential.name:
|
||||
# Validate name length
|
||||
if len(name) > 30:
|
||||
raise ValueError("Credential name must be 30 characters or less")
|
||||
|
||||
# Check if name is already used
|
||||
if session.scalar(
|
||||
select(
|
||||
exists().where(
|
||||
EndUserAuthenticationProvider.end_user_id == end_user_id,
|
||||
EndUserAuthenticationProvider.tenant_id == tenant_id,
|
||||
EndUserAuthenticationProvider.provider == db_credential.provider,
|
||||
EndUserAuthenticationProvider.name == name,
|
||||
)
|
||||
)
|
||||
):
|
||||
raise ValueError(f"The credential name '{name}' is already used")
|
||||
|
||||
db_credential.name = name
|
||||
|
||||
session.commit()
|
||||
session.refresh(db_credential)
|
||||
|
||||
# Return masked credentials
|
||||
decrypted = encrypter.decrypt(db_credential.credentials)
|
||||
masked_credentials = encrypter.mask_plugin_credentials(decrypted)
|
||||
|
||||
return ToolTransformService.convert_enduser_provider_to_credential_entity(
|
||||
provider=db_credential,
|
||||
credentials=dict(masked_credentials),
|
||||
)
|
||||
except Exception as e:
|
||||
session.rollback()
|
||||
logger.exception("Error updating credential")
|
||||
raise ValueError(str(e))
|
||||
|
||||
@staticmethod
|
||||
def delete_credential(
|
||||
tenant_id: str, end_user_id: str, provider_id: str, credential_id: str
|
||||
) -> bool:
|
||||
"""
|
||||
Delete a credential.
|
||||
|
||||
:param credential_id: The credential ID
|
||||
:param end_user_id: The end user ID
|
||||
:param tenant_id: The tenant ID
|
||||
:return: True if deleted successfully
|
||||
"""
|
||||
with Session(db.engine) as session:
|
||||
credential = (
|
||||
session.query(EndUserAuthenticationProvider)
|
||||
.filter_by(id=credential_id, end_user_id=end_user_id, tenant_id=tenant_id)
|
||||
.first()
|
||||
)
|
||||
|
||||
if not credential:
|
||||
raise ValueError(f"Credential {credential_id} not found")
|
||||
|
||||
session.delete(credential)
|
||||
session.commit()
|
||||
return True
|
||||
|
||||
@staticmethod
|
||||
def refresh_oauth_token(
|
||||
credential_id: str, end_user_id: str, tenant_id: str, refreshed_credentials: dict, expires_at: int
|
||||
) -> EndUserAuthenticationProvider:
|
||||
"""
|
||||
Update OAuth credentials after token refresh.
|
||||
|
||||
:param credential_id: The credential ID
|
||||
:param end_user_id: The end user ID
|
||||
:param tenant_id: The tenant ID
|
||||
:param refreshed_credentials: New credentials from OAuth refresh
|
||||
:param expires_at: New expiration timestamp
|
||||
:return: Updated credential record
|
||||
"""
|
||||
with Session(db.engine) as session:
|
||||
try:
|
||||
credential = (
|
||||
session.query(EndUserAuthenticationProvider)
|
||||
.filter_by(id=credential_id, end_user_id=end_user_id, tenant_id=tenant_id)
|
||||
.first()
|
||||
)
|
||||
|
||||
if not credential:
|
||||
raise ValueError(f"Credential {credential_id} not found")
|
||||
|
||||
if credential.credential_type != CredentialType.OAUTH2:
|
||||
raise ValueError("Only OAuth credentials can be refreshed")
|
||||
|
||||
# Get provider controller
|
||||
provider_controller = ToolManager.get_builtin_provider(credential.provider, tenant_id)
|
||||
|
||||
# Create encrypter
|
||||
encrypter, _ = EndUserAuthService._create_encrypter(
|
||||
tenant_id, provider_controller, credential.credential_type
|
||||
)
|
||||
|
||||
# Encrypt and save new credentials
|
||||
credential.encrypted_credentials = json.dumps(encrypter.encrypt(refreshed_credentials))
|
||||
credential.expires_at = expires_at
|
||||
|
||||
session.commit()
|
||||
session.refresh(credential)
|
||||
|
||||
return credential
|
||||
except Exception as e:
|
||||
session.rollback()
|
||||
logger.exception("Error refreshing OAuth token")
|
||||
raise ValueError(str(e))
|
||||
|
||||
@staticmethod
|
||||
def get_default_credential(
|
||||
end_user_id: str, tenant_id: str, provider: str
|
||||
) -> EndUserAuthenticationProvider | None:
|
||||
"""
|
||||
Get the default (oldest) credential for a provider.
|
||||
|
||||
:param end_user_id: The end user ID
|
||||
:param tenant_id: The tenant ID
|
||||
:param provider: The provider identifier
|
||||
:return: Credential record or None
|
||||
"""
|
||||
with Session(db.engine, autoflush=False) as session:
|
||||
return (
|
||||
session.query(EndUserAuthenticationProvider)
|
||||
.filter_by(end_user_id=end_user_id, tenant_id=tenant_id, provider=provider)
|
||||
.order_by(EndUserAuthenticationProvider.created_at.asc())
|
||||
.first()
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _generate_credential_name(
|
||||
session: Session,
|
||||
end_user_id: str,
|
||||
tenant_id: str,
|
||||
provider: str,
|
||||
credential_type: CredentialType,
|
||||
) -> str:
|
||||
"""
|
||||
Generate a unique credential name.
|
||||
|
||||
:param session: Database session
|
||||
:param end_user_id: The end user ID
|
||||
:param tenant_id: The tenant ID
|
||||
:param provider: The provider identifier
|
||||
:param credential_type: The credential type
|
||||
:return: Generated name (e.g., "API KEY 1", "AUTH 1")
|
||||
"""
|
||||
existing_credentials = (
|
||||
session.query(EndUserAuthenticationProvider)
|
||||
.filter_by(
|
||||
end_user_id=end_user_id,
|
||||
tenant_id=tenant_id,
|
||||
provider=provider,
|
||||
credential_type=credential_type,
|
||||
)
|
||||
.order_by(EndUserAuthenticationProvider.created_at.desc())
|
||||
.all()
|
||||
)
|
||||
|
||||
return generate_incremental_name(
|
||||
[credential.name for credential in existing_credentials],
|
||||
f"{credential_type.get_name()}",
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _create_encrypter(
|
||||
tenant_id: str, provider_controller, credential_type: CredentialType | str
|
||||
) -> tuple:
|
||||
"""
|
||||
Create an encrypter for credential encryption/decryption.
|
||||
|
||||
:param tenant_id: The tenant ID
|
||||
:param provider_controller: The provider controller
|
||||
:param credential_type: The credential type
|
||||
:return: Tuple of (encrypter, cache)
|
||||
"""
|
||||
if isinstance(credential_type, str):
|
||||
credential_type = CredentialType.of(credential_type)
|
||||
|
||||
return create_provider_encrypter(
|
||||
tenant_id=tenant_id,
|
||||
config=[
|
||||
x.to_basic_provider_config()
|
||||
for x in provider_controller.get_credentials_schema_by_type(credential_type)
|
||||
],
|
||||
cache=NoOpProviderCredentialCache(),
|
||||
)
|
||||
@@ -0,0 +1,264 @@
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from werkzeug import Request
|
||||
|
||||
from configs import dify_config
|
||||
from core.plugin.entities.plugin_daemon import CredentialType
|
||||
from core.plugin.impl.oauth import OAuthHandler
|
||||
from core.tools.tool_manager import ToolManager
|
||||
from models.provider_ids import ToolProviderID
|
||||
from services.plugin.oauth_service import OAuthProxyService
|
||||
from services.tools.builtin_tools_manage_service import BuiltinToolManageService
|
||||
from services.tools.enduser_auth_service import EndUserAuthService
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class EndUserOAuthService:
|
||||
"""
|
||||
Service for managing end-user OAuth authentication flows.
|
||||
Reuses existing OAuthProxyService and OAuthHandler infrastructure.
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def get_authorization_url(
|
||||
end_user_id: str,
|
||||
tenant_id: str,
|
||||
app_id: str,
|
||||
provider: str,
|
||||
) -> dict[str, str]:
|
||||
"""
|
||||
Initiate OAuth authorization flow for an end user.
|
||||
|
||||
:param end_user_id: The end user ID
|
||||
:param tenant_id: The tenant ID
|
||||
:param app_id: The application ID
|
||||
:param provider: The provider identifier
|
||||
:return: Dict with authorization_url
|
||||
"""
|
||||
try:
|
||||
# Get OAuth client configuration (reuse workspace-level logic)
|
||||
oauth_client = BuiltinToolManageService.get_oauth_client(tenant_id, provider)
|
||||
if not oauth_client:
|
||||
raise ValueError(f"OAuth client not configured for provider {provider}")
|
||||
|
||||
# Get provider controller
|
||||
provider_controller = ToolManager.get_builtin_provider(provider, tenant_id)
|
||||
tool_provider_id = ToolProviderID(provider)
|
||||
|
||||
# Create OAuth context with end-user specific data
|
||||
context_id = OAuthProxyService.create_proxy_context(
|
||||
user_id=end_user_id, # Using end_user_id as user_id
|
||||
tenant_id=tenant_id,
|
||||
plugin_id=tool_provider_id.plugin_id,
|
||||
provider=tool_provider_id.provider_name,
|
||||
extra_data={
|
||||
"app_id": app_id,
|
||||
"provider_type": "tool", # For now, only tools support end-user auth
|
||||
},
|
||||
)
|
||||
|
||||
# Use the same redirect URI as workspace OAuth to reuse the same OAuth client
|
||||
redirect_uri = f"{dify_config.CONSOLE_API_URL}/console/api/oauth/plugin/{tool_provider_id}/tool/callback"
|
||||
|
||||
# Get authorization URL from OAuth handler
|
||||
oauth_handler = OAuthHandler()
|
||||
response = oauth_handler.get_authorization_url(
|
||||
tenant_id=tenant_id,
|
||||
user_id=end_user_id,
|
||||
plugin_id=tool_provider_id.plugin_id,
|
||||
provider=tool_provider_id.provider_name,
|
||||
redirect_uri=redirect_uri,
|
||||
system_credentials=oauth_client,
|
||||
)
|
||||
|
||||
return {
|
||||
"authorization_url": response.authorization_url,
|
||||
"context_id": context_id, # Return for setting cookie
|
||||
}
|
||||
except Exception as e:
|
||||
logger.exception("Error getting authorization URL for end user")
|
||||
raise ValueError(f"Failed to initiate OAuth flow: {str(e)}")
|
||||
|
||||
@staticmethod
|
||||
def handle_oauth_callback(
|
||||
context_id: str,
|
||||
request: Request,
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
Handle OAuth callback and create credential.
|
||||
|
||||
:param context_id: The OAuth context ID from cookie
|
||||
:param request: The callback request with authorization code
|
||||
:return: Dict with credential information
|
||||
"""
|
||||
try:
|
||||
# Validate and retrieve context
|
||||
context = OAuthProxyService.use_proxy_context(context_id)
|
||||
|
||||
# Extract context data
|
||||
end_user_id = context.get("user_id") # user_id is actually end_user_id
|
||||
tenant_id = context.get("tenant_id")
|
||||
app_id = context.get("app_id")
|
||||
plugin_id = context.get("plugin_id")
|
||||
provider = context.get("provider")
|
||||
|
||||
if not all([end_user_id, tenant_id, app_id, plugin_id, provider]):
|
||||
raise ValueError("Invalid OAuth context: missing required fields")
|
||||
|
||||
# Reconstruct full provider ID
|
||||
full_provider = f"{plugin_id}/{provider}" if plugin_id != "langgenius" else provider
|
||||
|
||||
# Get OAuth client configuration
|
||||
oauth_client = BuiltinToolManageService.get_oauth_client(tenant_id, full_provider)
|
||||
if not oauth_client:
|
||||
raise ValueError(f"OAuth client not configured for provider {full_provider}")
|
||||
|
||||
# Use the same redirect URI as workspace OAuth (must match authorization request)
|
||||
redirect_uri = f"{dify_config.CONSOLE_API_URL}/console/api/oauth/plugin/{full_provider}/tool/callback"
|
||||
|
||||
# Exchange authorization code for credentials
|
||||
oauth_handler = OAuthHandler()
|
||||
credentials_response = oauth_handler.get_credentials(
|
||||
tenant_id=tenant_id,
|
||||
user_id=end_user_id,
|
||||
plugin_id=plugin_id,
|
||||
provider=provider,
|
||||
redirect_uri=redirect_uri,
|
||||
system_credentials=oauth_client,
|
||||
request=request,
|
||||
)
|
||||
|
||||
# Calculate expiration timestamp
|
||||
expires_at = -1
|
||||
if credentials_response.expires_in and credentials_response.expires_in > 0:
|
||||
import time
|
||||
|
||||
expires_at = int(time.time()) + credentials_response.expires_in
|
||||
|
||||
# Create credential in database
|
||||
credential = EndUserAuthService.create_oauth_credential(
|
||||
end_user_id=end_user_id,
|
||||
tenant_id=tenant_id,
|
||||
provider=full_provider,
|
||||
credentials=credentials_response.credentials,
|
||||
expires_at=expires_at,
|
||||
)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"credential_id": credential.id,
|
||||
"provider": full_provider,
|
||||
"app_id": app_id,
|
||||
}
|
||||
except Exception as e:
|
||||
logger.exception("Error handling OAuth callback for end user")
|
||||
raise ValueError(f"Failed to complete OAuth flow: {str(e)}")
|
||||
|
||||
@staticmethod
|
||||
def refresh_oauth_token(
|
||||
credential_id: str,
|
||||
end_user_id: str,
|
||||
tenant_id: str,
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
Refresh an expired OAuth token.
|
||||
|
||||
:param credential_id: The credential ID
|
||||
:param end_user_id: The end user ID
|
||||
:param tenant_id: The tenant ID
|
||||
:return: Dict with refresh status
|
||||
"""
|
||||
try:
|
||||
# Get existing credential
|
||||
credential = EndUserAuthService.get_credential(
|
||||
credential_id=credential_id,
|
||||
end_user_id=end_user_id,
|
||||
tenant_id=tenant_id,
|
||||
mask_credentials=False, # Need full credentials for refresh
|
||||
)
|
||||
|
||||
if not credential:
|
||||
raise ValueError(f"Credential {credential_id} not found")
|
||||
|
||||
if credential.credential_type != CredentialType.OAUTH2:
|
||||
raise ValueError("Only OAuth credentials can be refreshed")
|
||||
|
||||
# Get OAuth client configuration
|
||||
oauth_client = BuiltinToolManageService.get_oauth_client(tenant_id, credential.provider)
|
||||
if not oauth_client:
|
||||
raise ValueError(f"OAuth client not configured for provider {credential.provider}")
|
||||
|
||||
# Get provider info
|
||||
tool_provider_id = ToolProviderID(credential.provider)
|
||||
# Use the same redirect URI as workspace OAuth to reuse the same OAuth client
|
||||
redirect_uri = f"{dify_config.CONSOLE_API_URL}/console/api/oauth/plugin/{credential.provider}/tool/callback"
|
||||
|
||||
# Refresh credentials via OAuth handler
|
||||
oauth_handler = OAuthHandler()
|
||||
refreshed_response = oauth_handler.refresh_credentials(
|
||||
tenant_id=tenant_id,
|
||||
user_id=end_user_id,
|
||||
plugin_id=tool_provider_id.plugin_id,
|
||||
provider=tool_provider_id.provider_name,
|
||||
redirect_uri=redirect_uri,
|
||||
system_credentials=oauth_client,
|
||||
credentials=credential.credentials,
|
||||
)
|
||||
|
||||
# Calculate new expiration timestamp
|
||||
expires_at = -1
|
||||
if refreshed_response.expires_in and refreshed_response.expires_in > 0:
|
||||
import time
|
||||
|
||||
expires_at = int(time.time()) + refreshed_response.expires_in
|
||||
|
||||
# Update credential in database
|
||||
updated_credential = EndUserAuthService.refresh_oauth_token(
|
||||
credential_id=credential_id,
|
||||
end_user_id=end_user_id,
|
||||
tenant_id=tenant_id,
|
||||
refreshed_credentials=refreshed_response.credentials,
|
||||
expires_at=expires_at,
|
||||
)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"credential_id": updated_credential.id,
|
||||
"expires_at": expires_at,
|
||||
"refreshed_at": int(updated_credential.updated_at.timestamp()),
|
||||
}
|
||||
except Exception:
|
||||
logger.exception("Error refreshing OAuth token for end user")
|
||||
return {
|
||||
"success": False,
|
||||
"error": "Failed to refresh token",
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def get_oauth_client_info(tenant_id: str, provider: str) -> dict[str, Any]:
|
||||
"""
|
||||
Get OAuth client information for a provider.
|
||||
Used to check if OAuth is available and configured.
|
||||
|
||||
:param tenant_id: The tenant ID
|
||||
:param provider: The provider identifier
|
||||
:return: Dict with OAuth client info
|
||||
"""
|
||||
try:
|
||||
# Check if OAuth client exists (either system or custom)
|
||||
oauth_client = BuiltinToolManageService.get_oauth_client(tenant_id, provider)
|
||||
|
||||
return {
|
||||
"configured": oauth_client is not None,
|
||||
"system_configured": BuiltinToolManageService.is_oauth_system_client_exists(provider),
|
||||
"custom_configured": BuiltinToolManageService.is_oauth_custom_client_enabled(tenant_id, provider),
|
||||
}
|
||||
except Exception:
|
||||
logger.exception("Error getting OAuth client info")
|
||||
return {
|
||||
"configured": False,
|
||||
"system_configured": False,
|
||||
"custom_configured": False,
|
||||
}
|
||||
@@ -426,6 +426,27 @@ class ToolTransformService:
|
||||
credentials=credentials,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def convert_enduser_provider_to_credential_entity(
|
||||
provider, credentials: dict
|
||||
) -> ToolProviderCredentialApiEntity:
|
||||
"""
|
||||
Convert EndUserAuthenticationProvider to ToolProviderCredentialApiEntity.
|
||||
|
||||
:param provider: EndUserAuthenticationProvider instance
|
||||
:param credentials: Decrypted/masked credentials dict
|
||||
:return: ToolProviderCredentialApiEntity
|
||||
"""
|
||||
return ToolProviderCredentialApiEntity(
|
||||
id=provider.id,
|
||||
name=provider.name,
|
||||
provider=provider.provider,
|
||||
credential_type=CredentialType.of(provider.credential_type),
|
||||
is_default=False, # End-user credentials don't have default flag (use oldest)
|
||||
credentials=credentials,
|
||||
expires_at=provider.expires_at,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def convert_mcp_schema_to_parameter(schema: dict[str, Any]) -> list["ToolParameter"]:
|
||||
"""
|
||||
|
||||
@@ -410,9 +410,12 @@ class VariableTruncator(BaseTruncator):
|
||||
@overload
|
||||
def _truncate_json_primitives(self, val: None, target_size: int) -> _PartResult[None]: ...
|
||||
|
||||
@overload
|
||||
def _truncate_json_primitives(self, val: File, target_size: int) -> _PartResult[File]: ...
|
||||
|
||||
def _truncate_json_primitives(
|
||||
self,
|
||||
val: UpdatedVariable | str | list[object] | dict[str, object] | bool | int | float | None,
|
||||
val: UpdatedVariable | File | str | list[object] | dict[str, object] | bool | int | float | None,
|
||||
target_size: int,
|
||||
) -> _PartResult[Any]:
|
||||
"""Truncate a value within an object to fit within budget."""
|
||||
@@ -425,6 +428,9 @@ class VariableTruncator(BaseTruncator):
|
||||
return self._truncate_array(val, target_size)
|
||||
elif isinstance(val, dict):
|
||||
return self._truncate_object(val, target_size)
|
||||
elif isinstance(val, File):
|
||||
# File objects should not be truncated, return as-is
|
||||
return _PartResult(val, self.calculate_json_size(val), False)
|
||||
elif val is None or isinstance(val, (bool, int, float)):
|
||||
return _PartResult(val, self.calculate_json_size(val), False)
|
||||
else:
|
||||
|
||||
+21
-3
@@ -113,16 +113,31 @@ class TestShardedRedisBroadcastChannelIntegration:
|
||||
topic = broadcast_channel.topic(topic_name)
|
||||
producer = topic.as_producer()
|
||||
subscriptions = [topic.subscribe() for _ in range(subscriber_count)]
|
||||
ready_events = [threading.Event() for _ in range(subscriber_count)]
|
||||
|
||||
def producer_thread():
|
||||
time.sleep(0.2) # Allow all subscribers to connect
|
||||
deadline = time.time() + 5.0
|
||||
for ev in ready_events:
|
||||
remaining = deadline - time.time()
|
||||
if remaining <= 0:
|
||||
break
|
||||
if not ev.wait(timeout=max(0.0, remaining)):
|
||||
pytest.fail("subscriber did not become ready before publish deadline")
|
||||
producer.publish(message)
|
||||
time.sleep(0.2)
|
||||
for sub in subscriptions:
|
||||
sub.close()
|
||||
|
||||
def consumer_thread(subscription: Subscription) -> list[bytes]:
|
||||
def consumer_thread(subscription: Subscription, ready_event: threading.Event) -> list[bytes]:
|
||||
received_msgs = []
|
||||
# Prime subscription so the underlying Pub/Sub listener thread starts before publishing
|
||||
try:
|
||||
_ = subscription.receive(0.01)
|
||||
except SubscriptionClosedError:
|
||||
return received_msgs
|
||||
finally:
|
||||
ready_event.set()
|
||||
|
||||
while True:
|
||||
try:
|
||||
msg = subscription.receive(0.1)
|
||||
@@ -137,7 +152,10 @@ class TestShardedRedisBroadcastChannelIntegration:
|
||||
|
||||
with ThreadPoolExecutor(max_workers=subscriber_count + 1) as executor:
|
||||
producer_future = executor.submit(producer_thread)
|
||||
consumer_futures = [executor.submit(consumer_thread, subscription) for subscription in subscriptions]
|
||||
consumer_futures = [
|
||||
executor.submit(consumer_thread, subscription, ready_events[idx])
|
||||
for idx, subscription in enumerate(subscriptions)
|
||||
]
|
||||
|
||||
producer_future.result(timeout=10.0)
|
||||
msgs_by_consumers = []
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"""Test authentication security to prevent user enumeration."""
|
||||
|
||||
import base64
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
@@ -11,6 +12,11 @@ from controllers.console.auth.error import AuthenticationFailedError
|
||||
from controllers.console.auth.login import LoginApi
|
||||
|
||||
|
||||
def encode_password(password: str) -> str:
|
||||
"""Helper to encode password as Base64 for testing."""
|
||||
return base64.b64encode(password.encode("utf-8")).decode()
|
||||
|
||||
|
||||
class TestAuthenticationSecurity:
|
||||
"""Test authentication endpoints for security against user enumeration."""
|
||||
|
||||
@@ -42,7 +48,9 @@ class TestAuthenticationSecurity:
|
||||
|
||||
# Act
|
||||
with self.app.test_request_context(
|
||||
"/login", method="POST", json={"email": "nonexistent@example.com", "password": "WrongPass123!"}
|
||||
"/login",
|
||||
method="POST",
|
||||
json={"email": "nonexistent@example.com", "password": encode_password("WrongPass123!")},
|
||||
):
|
||||
login_api = LoginApi()
|
||||
|
||||
@@ -72,7 +80,9 @@ class TestAuthenticationSecurity:
|
||||
|
||||
# Act
|
||||
with self.app.test_request_context(
|
||||
"/login", method="POST", json={"email": "existing@example.com", "password": "WrongPass123!"}
|
||||
"/login",
|
||||
method="POST",
|
||||
json={"email": "existing@example.com", "password": encode_password("WrongPass123!")},
|
||||
):
|
||||
login_api = LoginApi()
|
||||
|
||||
@@ -104,7 +114,9 @@ class TestAuthenticationSecurity:
|
||||
|
||||
# Act
|
||||
with self.app.test_request_context(
|
||||
"/login", method="POST", json={"email": "nonexistent@example.com", "password": "WrongPass123!"}
|
||||
"/login",
|
||||
method="POST",
|
||||
json={"email": "nonexistent@example.com", "password": encode_password("WrongPass123!")},
|
||||
):
|
||||
login_api = LoginApi()
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ This module tests the email code login mechanism including:
|
||||
- Workspace creation for new users
|
||||
"""
|
||||
|
||||
import base64
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
@@ -25,6 +26,11 @@ from controllers.console.error import (
|
||||
from services.errors.account import AccountRegisterError
|
||||
|
||||
|
||||
def encode_code(code: str) -> str:
|
||||
"""Helper to encode verification code as Base64 for testing."""
|
||||
return base64.b64encode(code.encode("utf-8")).decode()
|
||||
|
||||
|
||||
class TestEmailCodeLoginSendEmailApi:
|
||||
"""Test cases for sending email verification codes."""
|
||||
|
||||
@@ -290,7 +296,7 @@ class TestEmailCodeLoginApi:
|
||||
with app.test_request_context(
|
||||
"/email-code-login/validity",
|
||||
method="POST",
|
||||
json={"email": "test@example.com", "code": "123456", "token": "valid_token"},
|
||||
json={"email": "test@example.com", "code": encode_code("123456"), "token": "valid_token"},
|
||||
):
|
||||
api = EmailCodeLoginApi()
|
||||
response = api.post()
|
||||
@@ -339,7 +345,12 @@ class TestEmailCodeLoginApi:
|
||||
with app.test_request_context(
|
||||
"/email-code-login/validity",
|
||||
method="POST",
|
||||
json={"email": "newuser@example.com", "code": "123456", "token": "valid_token", "language": "en-US"},
|
||||
json={
|
||||
"email": "newuser@example.com",
|
||||
"code": encode_code("123456"),
|
||||
"token": "valid_token",
|
||||
"language": "en-US",
|
||||
},
|
||||
):
|
||||
api = EmailCodeLoginApi()
|
||||
response = api.post()
|
||||
@@ -365,7 +376,7 @@ class TestEmailCodeLoginApi:
|
||||
with app.test_request_context(
|
||||
"/email-code-login/validity",
|
||||
method="POST",
|
||||
json={"email": "test@example.com", "code": "123456", "token": "invalid_token"},
|
||||
json={"email": "test@example.com", "code": encode_code("123456"), "token": "invalid_token"},
|
||||
):
|
||||
api = EmailCodeLoginApi()
|
||||
with pytest.raises(InvalidTokenError):
|
||||
@@ -388,7 +399,7 @@ class TestEmailCodeLoginApi:
|
||||
with app.test_request_context(
|
||||
"/email-code-login/validity",
|
||||
method="POST",
|
||||
json={"email": "different@example.com", "code": "123456", "token": "token"},
|
||||
json={"email": "different@example.com", "code": encode_code("123456"), "token": "token"},
|
||||
):
|
||||
api = EmailCodeLoginApi()
|
||||
with pytest.raises(InvalidEmailError):
|
||||
@@ -411,7 +422,7 @@ class TestEmailCodeLoginApi:
|
||||
with app.test_request_context(
|
||||
"/email-code-login/validity",
|
||||
method="POST",
|
||||
json={"email": "test@example.com", "code": "wrong_code", "token": "token"},
|
||||
json={"email": "test@example.com", "code": encode_code("wrong_code"), "token": "token"},
|
||||
):
|
||||
api = EmailCodeLoginApi()
|
||||
with pytest.raises(EmailCodeError):
|
||||
@@ -497,7 +508,7 @@ class TestEmailCodeLoginApi:
|
||||
with app.test_request_context(
|
||||
"/email-code-login/validity",
|
||||
method="POST",
|
||||
json={"email": "test@example.com", "code": "123456", "token": "token"},
|
||||
json={"email": "test@example.com", "code": encode_code("123456"), "token": "token"},
|
||||
):
|
||||
api = EmailCodeLoginApi()
|
||||
with pytest.raises(WorkspacesLimitExceeded):
|
||||
@@ -539,7 +550,7 @@ class TestEmailCodeLoginApi:
|
||||
with app.test_request_context(
|
||||
"/email-code-login/validity",
|
||||
method="POST",
|
||||
json={"email": "test@example.com", "code": "123456", "token": "token"},
|
||||
json={"email": "test@example.com", "code": encode_code("123456"), "token": "token"},
|
||||
):
|
||||
api = EmailCodeLoginApi()
|
||||
with pytest.raises(NotAllowedCreateWorkspace):
|
||||
|
||||
@@ -8,6 +8,7 @@ This module tests the core authentication endpoints including:
|
||||
- Account status validation
|
||||
"""
|
||||
|
||||
import base64
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
@@ -28,6 +29,11 @@ from controllers.console.error import (
|
||||
from services.errors.account import AccountLoginError, AccountPasswordError
|
||||
|
||||
|
||||
def encode_password(password: str) -> str:
|
||||
"""Helper to encode password as Base64 for testing."""
|
||||
return base64.b64encode(password.encode("utf-8")).decode()
|
||||
|
||||
|
||||
class TestLoginApi:
|
||||
"""Test cases for the LoginApi endpoint."""
|
||||
|
||||
@@ -106,7 +112,9 @@ class TestLoginApi:
|
||||
|
||||
# Act
|
||||
with app.test_request_context(
|
||||
"/login", method="POST", json={"email": "test@example.com", "password": "ValidPass123!"}
|
||||
"/login",
|
||||
method="POST",
|
||||
json={"email": "test@example.com", "password": encode_password("ValidPass123!")},
|
||||
):
|
||||
login_api = LoginApi()
|
||||
response = login_api.post()
|
||||
@@ -158,7 +166,11 @@ class TestLoginApi:
|
||||
with app.test_request_context(
|
||||
"/login",
|
||||
method="POST",
|
||||
json={"email": "test@example.com", "password": "ValidPass123!", "invite_token": "valid_token"},
|
||||
json={
|
||||
"email": "test@example.com",
|
||||
"password": encode_password("ValidPass123!"),
|
||||
"invite_token": "valid_token",
|
||||
},
|
||||
):
|
||||
login_api = LoginApi()
|
||||
response = login_api.post()
|
||||
@@ -186,7 +198,7 @@ class TestLoginApi:
|
||||
|
||||
# Act & Assert
|
||||
with app.test_request_context(
|
||||
"/login", method="POST", json={"email": "test@example.com", "password": "password"}
|
||||
"/login", method="POST", json={"email": "test@example.com", "password": encode_password("password")}
|
||||
):
|
||||
login_api = LoginApi()
|
||||
with pytest.raises(EmailPasswordLoginLimitError):
|
||||
@@ -209,7 +221,7 @@ class TestLoginApi:
|
||||
|
||||
# Act & Assert
|
||||
with app.test_request_context(
|
||||
"/login", method="POST", json={"email": "frozen@example.com", "password": "password"}
|
||||
"/login", method="POST", json={"email": "frozen@example.com", "password": encode_password("password")}
|
||||
):
|
||||
login_api = LoginApi()
|
||||
with pytest.raises(AccountInFreezeError):
|
||||
@@ -246,7 +258,7 @@ class TestLoginApi:
|
||||
|
||||
# Act & Assert
|
||||
with app.test_request_context(
|
||||
"/login", method="POST", json={"email": "test@example.com", "password": "WrongPass123!"}
|
||||
"/login", method="POST", json={"email": "test@example.com", "password": encode_password("WrongPass123!")}
|
||||
):
|
||||
login_api = LoginApi()
|
||||
with pytest.raises(AuthenticationFailedError):
|
||||
@@ -277,7 +289,7 @@ class TestLoginApi:
|
||||
|
||||
# Act & Assert
|
||||
with app.test_request_context(
|
||||
"/login", method="POST", json={"email": "banned@example.com", "password": "ValidPass123!"}
|
||||
"/login", method="POST", json={"email": "banned@example.com", "password": encode_password("ValidPass123!")}
|
||||
):
|
||||
login_api = LoginApi()
|
||||
with pytest.raises(AccountBannedError):
|
||||
@@ -322,7 +334,7 @@ class TestLoginApi:
|
||||
|
||||
# Act & Assert
|
||||
with app.test_request_context(
|
||||
"/login", method="POST", json={"email": "test@example.com", "password": "ValidPass123!"}
|
||||
"/login", method="POST", json={"email": "test@example.com", "password": encode_password("ValidPass123!")}
|
||||
):
|
||||
login_api = LoginApi()
|
||||
with pytest.raises(WorkspacesLimitExceeded):
|
||||
@@ -349,7 +361,11 @@ class TestLoginApi:
|
||||
with app.test_request_context(
|
||||
"/login",
|
||||
method="POST",
|
||||
json={"email": "different@example.com", "password": "ValidPass123!", "invite_token": "token"},
|
||||
json={
|
||||
"email": "different@example.com",
|
||||
"password": encode_password("ValidPass123!"),
|
||||
"invite_token": "token",
|
||||
},
|
||||
):
|
||||
login_api = LoginApi()
|
||||
with pytest.raises(InvalidEmailError):
|
||||
|
||||
@@ -0,0 +1,486 @@
|
||||
"""
|
||||
Unit tests for end-user tool authentication.
|
||||
|
||||
Tests the integration of end-user authentication with tool runtime resolution.
|
||||
"""
|
||||
|
||||
import time
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from core.app.entities.app_invoke_entities import InvokeFrom
|
||||
from core.tools.entities.tool_entities import ToolAuthType, ToolInvokeFrom, ToolProviderType
|
||||
from core.tools.errors import ToolProviderNotFoundError
|
||||
from core.tools.tool_manager import ToolManager
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_db_session():
|
||||
"""Mock database session."""
|
||||
with patch("core.tools.tool_manager.db") as mock_db:
|
||||
yield mock_db.session
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_provider_controller():
|
||||
"""Mock builtin provider controller."""
|
||||
controller = MagicMock()
|
||||
controller.need_credentials = True
|
||||
controller.get_tool = MagicMock(return_value=MagicMock())
|
||||
controller.get_credentials_schema_by_type = MagicMock(return_value=[])
|
||||
return controller
|
||||
|
||||
|
||||
class TestEndUserToolAuthentication:
|
||||
"""Test suite for end-user tool authentication."""
|
||||
|
||||
def test_end_user_auth_requires_end_user_id(self, mock_db_session, mock_provider_controller):
|
||||
"""
|
||||
Test that END_USER auth_type requires end_user_id parameter.
|
||||
|
||||
When auth_type is END_USER but end_user_id is None, should raise error.
|
||||
"""
|
||||
with patch.object(ToolManager, "get_builtin_provider", return_value=mock_provider_controller):
|
||||
with pytest.raises(ToolProviderNotFoundError, match="end_user_id is required"):
|
||||
ToolManager.get_tool_runtime(
|
||||
provider_type=ToolProviderType.BUILT_IN,
|
||||
provider_id="test_provider",
|
||||
tool_name="test_tool",
|
||||
tenant_id="test_tenant",
|
||||
invoke_from=InvokeFrom.SERVICE_API,
|
||||
tool_invoke_from=ToolInvokeFrom.WORKFLOW,
|
||||
auth_type=ToolAuthType.END_USER,
|
||||
end_user_id=None, # Missing!
|
||||
)
|
||||
|
||||
def test_end_user_auth_missing_credentials(self, mock_db_session, mock_provider_controller):
|
||||
"""
|
||||
Test that error is raised when end-user has no credentials for provider.
|
||||
|
||||
When auth_type is END_USER but no credentials exist, should raise error.
|
||||
"""
|
||||
# Mock no credentials found
|
||||
mock_db_session.query.return_value.where.return_value.order_by.return_value.first.return_value = None
|
||||
|
||||
with patch.object(ToolManager, "get_builtin_provider", return_value=mock_provider_controller):
|
||||
with pytest.raises(ToolProviderNotFoundError, match="No end-user credentials found"):
|
||||
ToolManager.get_tool_runtime(
|
||||
provider_type=ToolProviderType.BUILT_IN,
|
||||
provider_id="test_provider",
|
||||
tool_name="test_tool",
|
||||
tenant_id="test_tenant",
|
||||
invoke_from=InvokeFrom.SERVICE_API,
|
||||
tool_invoke_from=ToolInvokeFrom.WORKFLOW,
|
||||
auth_type=ToolAuthType.END_USER,
|
||||
end_user_id="end_user_123",
|
||||
)
|
||||
|
||||
def test_end_user_auth_with_credentials(self, mock_db_session, mock_provider_controller):
|
||||
"""
|
||||
Test successful end-user credential resolution.
|
||||
|
||||
When auth_type is END_USER and credentials exist, should return tool runtime.
|
||||
"""
|
||||
# Mock end-user provider
|
||||
mock_enduser_provider = MagicMock()
|
||||
mock_enduser_provider.id = "cred_123"
|
||||
mock_enduser_provider.credential_type = "api-key"
|
||||
mock_enduser_provider.credentials = '{"api_key": "encrypted"}'
|
||||
mock_enduser_provider.expires_at = -1 # No expiry
|
||||
|
||||
mock_db_session.query.return_value.where.return_value.order_by.return_value.first.return_value = (
|
||||
mock_enduser_provider
|
||||
)
|
||||
|
||||
# Mock encrypter
|
||||
mock_encrypter = MagicMock()
|
||||
mock_encrypter.decrypt.return_value = {"api_key": "decrypted_key"}
|
||||
mock_cache = MagicMock()
|
||||
|
||||
with (
|
||||
patch.object(ToolManager, "get_builtin_provider", return_value=mock_provider_controller),
|
||||
patch("core.tools.tool_manager.create_provider_encrypter", return_value=(mock_encrypter, mock_cache)),
|
||||
):
|
||||
tool_runtime = ToolManager.get_tool_runtime(
|
||||
provider_type=ToolProviderType.BUILT_IN,
|
||||
provider_id="test_provider",
|
||||
tool_name="test_tool",
|
||||
tenant_id="test_tenant",
|
||||
invoke_from=InvokeFrom.SERVICE_API,
|
||||
tool_invoke_from=ToolInvokeFrom.WORKFLOW,
|
||||
auth_type=ToolAuthType.END_USER,
|
||||
end_user_id="end_user_123",
|
||||
)
|
||||
|
||||
# Verify tool runtime was created
|
||||
assert tool_runtime is not None
|
||||
# Verify encrypter was called with decrypted credentials
|
||||
mock_encrypter.decrypt.assert_called_once()
|
||||
|
||||
def test_workspace_auth_backward_compatibility(self, mock_db_session, mock_provider_controller):
|
||||
"""
|
||||
Test that workspace authentication still works (backward compatibility).
|
||||
|
||||
When auth_type is WORKSPACE (default), should use workspace credentials.
|
||||
"""
|
||||
# Mock workspace provider
|
||||
mock_workspace_provider = MagicMock()
|
||||
mock_workspace_provider.id = "workspace_cred_123"
|
||||
mock_workspace_provider.credential_type = "api-key"
|
||||
mock_workspace_provider.credentials = '{"api_key": "workspace_encrypted"}'
|
||||
mock_workspace_provider.expires_at = -1
|
||||
|
||||
mock_db_session.query.return_value.where.return_value.order_by.return_value.first.return_value = (
|
||||
mock_workspace_provider
|
||||
)
|
||||
|
||||
# Mock encrypter
|
||||
mock_encrypter = MagicMock()
|
||||
mock_encrypter.decrypt.return_value = {"api_key": "workspace_decrypted"}
|
||||
mock_cache = MagicMock()
|
||||
|
||||
with (
|
||||
patch.object(ToolManager, "get_builtin_provider", return_value=mock_provider_controller),
|
||||
patch("core.tools.tool_manager.create_provider_encrypter", return_value=(mock_encrypter, mock_cache)),
|
||||
patch("core.helper.credential_utils.check_credential_policy_compliance"),
|
||||
):
|
||||
tool_runtime = ToolManager.get_tool_runtime(
|
||||
provider_type=ToolProviderType.BUILT_IN,
|
||||
provider_id="test_provider",
|
||||
tool_name="test_tool",
|
||||
tenant_id="test_tenant",
|
||||
invoke_from=InvokeFrom.SERVICE_API,
|
||||
tool_invoke_from=ToolInvokeFrom.WORKFLOW,
|
||||
auth_type=ToolAuthType.WORKSPACE, # Workspace auth
|
||||
end_user_id=None, # Not needed for workspace auth
|
||||
)
|
||||
|
||||
# Verify tool runtime was created
|
||||
assert tool_runtime is not None
|
||||
|
||||
def test_workflow_tool_runtime_passes_end_user_id(self, mock_db_session, mock_provider_controller):
|
||||
"""
|
||||
Test that get_workflow_tool_runtime correctly passes end_user_id to get_tool_runtime.
|
||||
"""
|
||||
from core.workflow.nodes.tool.entities import ToolEntity
|
||||
|
||||
# Create a mock ToolEntity with END_USER auth_type
|
||||
workflow_tool = MagicMock(spec=ToolEntity)
|
||||
workflow_tool.provider_type = ToolProviderType.BUILT_IN
|
||||
workflow_tool.provider_id = "test_provider"
|
||||
workflow_tool.tool_name = "test_tool"
|
||||
workflow_tool.credential_id = None
|
||||
workflow_tool.auth_type = ToolAuthType.END_USER
|
||||
workflow_tool.tool_configurations = {}
|
||||
|
||||
# Mock end-user credentials
|
||||
mock_enduser_provider = MagicMock()
|
||||
mock_enduser_provider.id = "cred_123"
|
||||
mock_enduser_provider.credential_type = "api-key"
|
||||
mock_enduser_provider.credentials = '{"api_key": "encrypted"}'
|
||||
mock_enduser_provider.expires_at = -1
|
||||
|
||||
mock_db_session.query.return_value.where.return_value.order_by.return_value.first.return_value = (
|
||||
mock_enduser_provider
|
||||
)
|
||||
|
||||
mock_encrypter = MagicMock()
|
||||
mock_encrypter.decrypt.return_value = {"api_key": "decrypted"}
|
||||
mock_cache = MagicMock()
|
||||
|
||||
with (
|
||||
patch.object(ToolManager, "get_builtin_provider", return_value=mock_provider_controller),
|
||||
patch("core.tools.tool_manager.create_provider_encrypter", return_value=(mock_encrypter, mock_cache)),
|
||||
):
|
||||
tool_runtime = ToolManager.get_workflow_tool_runtime(
|
||||
tenant_id="test_tenant",
|
||||
app_id="test_app",
|
||||
node_id="test_node",
|
||||
workflow_tool=workflow_tool,
|
||||
invoke_from=InvokeFrom.SERVICE_API,
|
||||
variable_pool=None,
|
||||
end_user_id="end_user_123", # Pass end_user_id
|
||||
)
|
||||
|
||||
# Verify tool runtime was created
|
||||
assert tool_runtime is not None
|
||||
|
||||
|
||||
class TestOAuthTokenRefresh:
|
||||
"""Test suite for OAuth token refresh functionality."""
|
||||
|
||||
def test_enduser_oauth_token_refresh_when_expired(self, mock_db_session, mock_provider_controller):
|
||||
"""
|
||||
Test that end-user OAuth tokens are automatically refreshed when expired.
|
||||
|
||||
When an OAuth token is expired (expires_at < current_time + 60s buffer),
|
||||
the system should automatically refresh it before using.
|
||||
"""
|
||||
# Mock end-user provider with expired OAuth token
|
||||
mock_enduser_provider = MagicMock()
|
||||
mock_enduser_provider.id = "cred_123"
|
||||
mock_enduser_provider.credential_type = "oauth2"
|
||||
mock_enduser_provider.credentials = '{"access_token": "old_token", "refresh_token": "refresh"}'
|
||||
# Set expiry to past (token expired)
|
||||
mock_enduser_provider.expires_at = int(time.time()) - 100
|
||||
mock_enduser_provider.encrypted_credentials = '{"access_token": "old_token", "refresh_token": "refresh"}'
|
||||
|
||||
mock_db_session.query.return_value.where.return_value.order_by.return_value.first.return_value = (
|
||||
mock_enduser_provider
|
||||
)
|
||||
|
||||
# Mock encrypter
|
||||
mock_encrypter = MagicMock()
|
||||
mock_encrypter.decrypt.return_value = {"access_token": "old_token", "refresh_token": "refresh"}
|
||||
mock_encrypter.encrypt.return_value = {"access_token": "new_token", "refresh_token": "refresh"}
|
||||
mock_cache = MagicMock()
|
||||
|
||||
# Mock OAuth refresh response
|
||||
mock_refreshed_credentials = MagicMock()
|
||||
mock_refreshed_credentials.credentials = {"access_token": "new_token", "refresh_token": "refresh"}
|
||||
mock_refreshed_credentials.expires_at = int(time.time()) + 3600 # New expiry 1 hour from now
|
||||
|
||||
with (
|
||||
patch.object(ToolManager, "get_builtin_provider", return_value=mock_provider_controller),
|
||||
patch("core.tools.tool_manager.create_provider_encrypter", return_value=(mock_encrypter, mock_cache)),
|
||||
patch.object(
|
||||
ToolManager,
|
||||
"_refresh_oauth_credentials",
|
||||
return_value=(
|
||||
mock_refreshed_credentials.credentials,
|
||||
mock_refreshed_credentials.expires_at,
|
||||
),
|
||||
) as mock_refresh,
|
||||
):
|
||||
tool_runtime = ToolManager.get_tool_runtime(
|
||||
provider_type=ToolProviderType.BUILT_IN,
|
||||
provider_id="github",
|
||||
tool_name="test_tool",
|
||||
tenant_id="test_tenant",
|
||||
invoke_from=InvokeFrom.SERVICE_API,
|
||||
tool_invoke_from=ToolInvokeFrom.WORKFLOW,
|
||||
auth_type=ToolAuthType.END_USER,
|
||||
end_user_id="end_user_123",
|
||||
)
|
||||
|
||||
# Verify refresh was called
|
||||
mock_refresh.assert_called_once_with(
|
||||
tenant_id="test_tenant",
|
||||
provider_id="github",
|
||||
user_id="end_user_123",
|
||||
decrypted_credentials={"access_token": "old_token", "refresh_token": "refresh"},
|
||||
)
|
||||
|
||||
# Verify provider was updated with new credentials
|
||||
assert mock_enduser_provider.expires_at == mock_refreshed_credentials.expires_at
|
||||
mock_db_session.commit.assert_called_once()
|
||||
mock_cache.delete.assert_called_once()
|
||||
|
||||
# Verify tool runtime was created
|
||||
assert tool_runtime is not None
|
||||
|
||||
def test_enduser_oauth_token_not_refreshed_when_valid(self, mock_db_session, mock_provider_controller):
|
||||
"""
|
||||
Test that valid OAuth tokens are NOT refreshed.
|
||||
|
||||
When an OAuth token is still valid (expires_at > current_time + 60s buffer),
|
||||
the system should use it without refreshing.
|
||||
"""
|
||||
# Mock end-user provider with valid OAuth token
|
||||
mock_enduser_provider = MagicMock()
|
||||
mock_enduser_provider.id = "cred_123"
|
||||
mock_enduser_provider.credential_type = "oauth2"
|
||||
mock_enduser_provider.credentials = '{"access_token": "valid_token", "refresh_token": "refresh"}'
|
||||
# Set expiry to future (token still valid with buffer)
|
||||
mock_enduser_provider.expires_at = int(time.time()) + 3600 # Valid for 1 hour
|
||||
|
||||
mock_db_session.query.return_value.where.return_value.order_by.return_value.first.return_value = (
|
||||
mock_enduser_provider
|
||||
)
|
||||
|
||||
# Mock encrypter
|
||||
mock_encrypter = MagicMock()
|
||||
mock_encrypter.decrypt.return_value = {"access_token": "valid_token", "refresh_token": "refresh"}
|
||||
mock_cache = MagicMock()
|
||||
|
||||
with (
|
||||
patch.object(ToolManager, "get_builtin_provider", return_value=mock_provider_controller),
|
||||
patch("core.tools.tool_manager.create_provider_encrypter", return_value=(mock_encrypter, mock_cache)),
|
||||
patch.object(ToolManager, "_refresh_oauth_credentials") as mock_refresh,
|
||||
):
|
||||
tool_runtime = ToolManager.get_tool_runtime(
|
||||
provider_type=ToolProviderType.BUILT_IN,
|
||||
provider_id="github",
|
||||
tool_name="test_tool",
|
||||
tenant_id="test_tenant",
|
||||
invoke_from=InvokeFrom.SERVICE_API,
|
||||
tool_invoke_from=ToolInvokeFrom.WORKFLOW,
|
||||
auth_type=ToolAuthType.END_USER,
|
||||
end_user_id="end_user_123",
|
||||
)
|
||||
|
||||
# Verify refresh was NOT called (token still valid)
|
||||
mock_refresh.assert_not_called()
|
||||
|
||||
# Verify tool runtime was created with original credentials
|
||||
assert tool_runtime is not None
|
||||
|
||||
def test_workspace_oauth_token_refresh_when_expired(self, mock_db_session, mock_provider_controller):
|
||||
"""
|
||||
Test that workspace OAuth tokens are automatically refreshed when expired.
|
||||
|
||||
This ensures the refactored _refresh_oauth_credentials helper works
|
||||
for both end-user and workspace authentication flows.
|
||||
"""
|
||||
# Mock workspace provider with expired OAuth token
|
||||
mock_workspace_provider = MagicMock()
|
||||
mock_workspace_provider.id = "workspace_cred_123"
|
||||
mock_workspace_provider.user_id = "workspace_user_456"
|
||||
mock_workspace_provider.credential_type = "oauth2"
|
||||
mock_workspace_provider.credentials = '{"access_token": "old_workspace_token"}'
|
||||
mock_workspace_provider.expires_at = int(time.time()) - 100 # Expired
|
||||
mock_workspace_provider.encrypted_credentials = '{"access_token": "old_workspace_token"}'
|
||||
|
||||
mock_db_session.query.return_value.where.return_value.order_by.return_value.first.return_value = (
|
||||
mock_workspace_provider
|
||||
)
|
||||
|
||||
# Mock encrypter
|
||||
mock_encrypter = MagicMock()
|
||||
mock_encrypter.decrypt.return_value = {"access_token": "old_workspace_token"}
|
||||
mock_encrypter.encrypt.return_value = {"access_token": "new_workspace_token"}
|
||||
mock_cache = MagicMock()
|
||||
|
||||
# Mock OAuth refresh response
|
||||
refreshed_creds = {"access_token": "new_workspace_token"}
|
||||
new_expires_at = int(time.time()) + 3600
|
||||
|
||||
with (
|
||||
patch.object(ToolManager, "get_builtin_provider", return_value=mock_provider_controller),
|
||||
patch("core.tools.tool_manager.create_provider_encrypter", return_value=(mock_encrypter, mock_cache)),
|
||||
patch("core.helper.credential_utils.check_credential_policy_compliance"),
|
||||
patch.object(
|
||||
ToolManager, "_refresh_oauth_credentials", return_value=(refreshed_creds, new_expires_at)
|
||||
) as mock_refresh,
|
||||
):
|
||||
tool_runtime = ToolManager.get_tool_runtime(
|
||||
provider_type=ToolProviderType.BUILT_IN,
|
||||
provider_id="github",
|
||||
tool_name="test_tool",
|
||||
tenant_id="test_tenant",
|
||||
invoke_from=InvokeFrom.SERVICE_API,
|
||||
tool_invoke_from=ToolInvokeFrom.WORKFLOW,
|
||||
auth_type=ToolAuthType.WORKSPACE,
|
||||
)
|
||||
|
||||
# Verify refresh was called with workspace user_id
|
||||
mock_refresh.assert_called_once_with(
|
||||
tenant_id="test_tenant",
|
||||
provider_id="github",
|
||||
user_id="workspace_user_456",
|
||||
decrypted_credentials={"access_token": "old_workspace_token"},
|
||||
)
|
||||
|
||||
# Verify provider was updated
|
||||
assert mock_workspace_provider.expires_at == new_expires_at
|
||||
mock_db_session.commit.assert_called_once()
|
||||
mock_cache.delete.assert_called_once()
|
||||
|
||||
# Verify tool runtime was created
|
||||
assert tool_runtime is not None
|
||||
|
||||
def test_oauth_token_no_refresh_for_non_oauth_credentials(self, mock_db_session, mock_provider_controller):
|
||||
"""
|
||||
Test that non-OAuth credentials (API keys) are never refreshed.
|
||||
|
||||
API keys with expires_at = -1 should not trigger refresh logic.
|
||||
"""
|
||||
# Mock end-user provider with API key (no expiry)
|
||||
mock_enduser_provider = MagicMock()
|
||||
mock_enduser_provider.id = "cred_123"
|
||||
mock_enduser_provider.credential_type = "api-key"
|
||||
mock_enduser_provider.credentials = '{"api_key": "sk-1234567890"}'
|
||||
mock_enduser_provider.expires_at = -1 # API keys don't expire
|
||||
|
||||
mock_db_session.query.return_value.where.return_value.order_by.return_value.first.return_value = (
|
||||
mock_enduser_provider
|
||||
)
|
||||
|
||||
# Mock encrypter
|
||||
mock_encrypter = MagicMock()
|
||||
mock_encrypter.decrypt.return_value = {"api_key": "sk-1234567890"}
|
||||
mock_cache = MagicMock()
|
||||
|
||||
with (
|
||||
patch.object(ToolManager, "get_builtin_provider", return_value=mock_provider_controller),
|
||||
patch("core.tools.tool_manager.create_provider_encrypter", return_value=(mock_encrypter, mock_cache)),
|
||||
patch.object(ToolManager, "_refresh_oauth_credentials") as mock_refresh,
|
||||
):
|
||||
tool_runtime = ToolManager.get_tool_runtime(
|
||||
provider_type=ToolProviderType.BUILT_IN,
|
||||
provider_id="openai",
|
||||
tool_name="test_tool",
|
||||
tenant_id="test_tenant",
|
||||
invoke_from=InvokeFrom.SERVICE_API,
|
||||
tool_invoke_from=ToolInvokeFrom.WORKFLOW,
|
||||
auth_type=ToolAuthType.END_USER,
|
||||
end_user_id="end_user_123",
|
||||
)
|
||||
|
||||
# Verify refresh was NOT called (API key doesn't need refresh)
|
||||
mock_refresh.assert_not_called()
|
||||
|
||||
# Verify tool runtime was created
|
||||
assert tool_runtime is not None
|
||||
|
||||
def test_refresh_oauth_credentials_helper_method(self):
|
||||
"""
|
||||
Test the _refresh_oauth_credentials helper method directly.
|
||||
|
||||
This tests the centralized OAuth refresh logic that is used by both
|
||||
end-user and workspace authentication flows.
|
||||
"""
|
||||
# Mock dependencies
|
||||
mock_oauth_handler = MagicMock()
|
||||
mock_refreshed = MagicMock()
|
||||
mock_refreshed.credentials = {"access_token": "new_token", "refresh_token": "new_refresh"}
|
||||
mock_refreshed.expires_at = int(time.time()) + 7200
|
||||
mock_oauth_handler.refresh_credentials.return_value = mock_refreshed
|
||||
|
||||
with (
|
||||
# Patch OAuthHandler where it's imported (inside the method)
|
||||
patch("core.plugin.impl.oauth.OAuthHandler", return_value=mock_oauth_handler),
|
||||
patch("core.tools.tool_manager.ToolProviderID") as mock_provider_id,
|
||||
patch(
|
||||
"services.tools.builtin_tools_manage_service.BuiltinToolManageService.get_oauth_client",
|
||||
return_value={"client_id": "test"},
|
||||
),
|
||||
patch("core.tools.tool_manager.dify_config.CONSOLE_API_URL", "http://localhost:5001"),
|
||||
):
|
||||
# Setup provider ID mock
|
||||
mock_provider_id.return_value.provider_name = "github"
|
||||
mock_provider_id.return_value.plugin_id = "builtin"
|
||||
|
||||
# Call the helper method
|
||||
credentials, expires_at = ToolManager._refresh_oauth_credentials(
|
||||
tenant_id="test_tenant",
|
||||
provider_id="langgenius/github/github",
|
||||
user_id="user_123",
|
||||
decrypted_credentials={"access_token": "old_token", "refresh_token": "old_refresh"},
|
||||
)
|
||||
|
||||
# Verify OAuth handler was called correctly
|
||||
mock_oauth_handler.refresh_credentials.assert_called_once_with(
|
||||
tenant_id="test_tenant",
|
||||
user_id="user_123",
|
||||
plugin_id="builtin",
|
||||
provider="github",
|
||||
redirect_uri="http://localhost:5001/console/api/oauth/plugin/langgenius/github/github/tool/callback",
|
||||
system_credentials={"client_id": "test"},
|
||||
credentials={"access_token": "old_token", "refresh_token": "old_refresh"},
|
||||
)
|
||||
|
||||
# Verify returned values
|
||||
assert credentials == {"access_token": "new_token", "refresh_token": "new_refresh"}
|
||||
assert expires_at == mock_refreshed.expires_at
|
||||
@@ -0,0 +1,150 @@
|
||||
"""
|
||||
Unit tests for field encoding/decoding utilities.
|
||||
|
||||
These tests verify Base64 encoding/decoding functionality and
|
||||
proper error handling and fallback behavior.
|
||||
"""
|
||||
|
||||
import base64
|
||||
|
||||
from libs.encryption import FieldEncryption
|
||||
|
||||
|
||||
class TestDecodeField:
|
||||
"""Test cases for field decoding functionality."""
|
||||
|
||||
def test_decode_valid_base64(self):
|
||||
"""Test decoding a valid Base64 encoded string."""
|
||||
plaintext = "password123"
|
||||
encoded = base64.b64encode(plaintext.encode("utf-8")).decode()
|
||||
|
||||
result = FieldEncryption.decrypt_field(encoded)
|
||||
assert result == plaintext
|
||||
|
||||
def test_decode_non_base64_returns_none(self):
|
||||
"""Test that non-base64 input returns None."""
|
||||
non_base64 = "plain-password-!@#"
|
||||
result = FieldEncryption.decrypt_field(non_base64)
|
||||
# Should return None (decoding failed)
|
||||
assert result is None
|
||||
|
||||
def test_decode_unicode_text(self):
|
||||
"""Test decoding Base64 encoded Unicode text."""
|
||||
plaintext = "密码Test123"
|
||||
encoded = base64.b64encode(plaintext.encode("utf-8")).decode()
|
||||
|
||||
result = FieldEncryption.decrypt_field(encoded)
|
||||
assert result == plaintext
|
||||
|
||||
def test_decode_empty_string(self):
|
||||
"""Test decoding an empty string returns empty string."""
|
||||
result = FieldEncryption.decrypt_field("")
|
||||
# Empty string base64 decodes to empty string
|
||||
assert result == ""
|
||||
|
||||
def test_decode_special_characters(self):
|
||||
"""Test decoding with special characters."""
|
||||
plaintext = "P@ssw0rd!#$%^&*()"
|
||||
encoded = base64.b64encode(plaintext.encode("utf-8")).decode()
|
||||
|
||||
result = FieldEncryption.decrypt_field(encoded)
|
||||
assert result == plaintext
|
||||
|
||||
|
||||
class TestDecodePassword:
|
||||
"""Test cases for password decoding."""
|
||||
|
||||
def test_decode_password_base64(self):
|
||||
"""Test decoding a Base64 encoded password."""
|
||||
password = "SecureP@ssw0rd!"
|
||||
encoded = base64.b64encode(password.encode("utf-8")).decode()
|
||||
|
||||
result = FieldEncryption.decrypt_password(encoded)
|
||||
assert result == password
|
||||
|
||||
def test_decode_password_invalid_returns_none(self):
|
||||
"""Test that invalid base64 passwords return None."""
|
||||
invalid = "PlainPassword!@#"
|
||||
result = FieldEncryption.decrypt_password(invalid)
|
||||
# Should return None (decoding failed)
|
||||
assert result is None
|
||||
|
||||
|
||||
class TestDecodeVerificationCode:
|
||||
"""Test cases for verification code decoding."""
|
||||
|
||||
def test_decode_code_base64(self):
|
||||
"""Test decoding a Base64 encoded verification code."""
|
||||
code = "789012"
|
||||
encoded = base64.b64encode(code.encode("utf-8")).decode()
|
||||
|
||||
result = FieldEncryption.decrypt_verification_code(encoded)
|
||||
assert result == code
|
||||
|
||||
def test_decode_code_invalid_returns_none(self):
|
||||
"""Test that invalid base64 codes return None."""
|
||||
invalid = "123456" # Plain 6-digit code, not base64
|
||||
result = FieldEncryption.decrypt_verification_code(invalid)
|
||||
# Should return None (decoding failed)
|
||||
assert result is None
|
||||
|
||||
|
||||
class TestRoundTripEncodingDecoding:
|
||||
"""
|
||||
Integration tests for complete encoding-decoding cycle.
|
||||
These tests simulate the full frontend-to-backend flow using Base64.
|
||||
"""
|
||||
|
||||
def test_roundtrip_password(self):
|
||||
"""Test encoding and decoding a password."""
|
||||
original_password = "SecureP@ssw0rd!"
|
||||
|
||||
# Simulate frontend encoding (Base64)
|
||||
encoded = base64.b64encode(original_password.encode("utf-8")).decode()
|
||||
|
||||
# Backend decoding
|
||||
decoded = FieldEncryption.decrypt_password(encoded)
|
||||
|
||||
assert decoded == original_password
|
||||
|
||||
def test_roundtrip_verification_code(self):
|
||||
"""Test encoding and decoding a verification code."""
|
||||
original_code = "123456"
|
||||
|
||||
# Simulate frontend encoding
|
||||
encoded = base64.b64encode(original_code.encode("utf-8")).decode()
|
||||
|
||||
# Backend decoding
|
||||
decoded = FieldEncryption.decrypt_verification_code(encoded)
|
||||
|
||||
assert decoded == original_code
|
||||
|
||||
def test_roundtrip_unicode_password(self):
|
||||
"""Test encoding and decoding password with Unicode characters."""
|
||||
original_password = "密码Test123!@#"
|
||||
|
||||
# Frontend encoding
|
||||
encoded = base64.b64encode(original_password.encode("utf-8")).decode()
|
||||
|
||||
# Backend decoding
|
||||
decoded = FieldEncryption.decrypt_password(encoded)
|
||||
|
||||
assert decoded == original_password
|
||||
|
||||
def test_roundtrip_long_password(self):
|
||||
"""Test encoding and decoding a long password."""
|
||||
original_password = "ThisIsAVeryLongPasswordWithLotsOfCharacters123!@#$%^&*()"
|
||||
|
||||
encoded = base64.b64encode(original_password.encode("utf-8")).decode()
|
||||
decoded = FieldEncryption.decrypt_password(encoded)
|
||||
|
||||
assert decoded == original_password
|
||||
|
||||
def test_roundtrip_with_whitespace(self):
|
||||
"""Test encoding and decoding with whitespace."""
|
||||
original_password = "pass word with spaces"
|
||||
|
||||
encoded = base64.b64encode(original_password.encode("utf-8")).decode()
|
||||
decoded = FieldEncryption.decrypt_field(encoded)
|
||||
|
||||
assert decoded == original_password
|
||||
@@ -518,6 +518,55 @@ class TestEdgeCases:
|
||||
assert isinstance(result.result, StringSegment)
|
||||
|
||||
|
||||
class TestTruncateJsonPrimitives:
|
||||
"""Test _truncate_json_primitives method with different data types."""
|
||||
|
||||
@pytest.fixture
|
||||
def truncator(self):
|
||||
return VariableTruncator()
|
||||
|
||||
def test_truncate_json_primitives_file_type(self, truncator, file):
|
||||
"""Test that File objects are handled correctly in _truncate_json_primitives."""
|
||||
# Test File object is returned as-is without truncation
|
||||
result = truncator._truncate_json_primitives(file, 1000)
|
||||
|
||||
assert result.value == file
|
||||
assert result.truncated is False
|
||||
# Size should be calculated correctly
|
||||
expected_size = VariableTruncator.calculate_json_size(file)
|
||||
assert result.value_size == expected_size
|
||||
|
||||
def test_truncate_json_primitives_file_type_small_budget(self, truncator, file):
|
||||
"""Test that File objects are returned as-is even with small budget."""
|
||||
# Even with a small size budget, File objects should not be truncated
|
||||
result = truncator._truncate_json_primitives(file, 10)
|
||||
|
||||
assert result.value == file
|
||||
assert result.truncated is False
|
||||
|
||||
def test_truncate_json_primitives_file_type_in_array(self, truncator, file):
|
||||
"""Test File objects in arrays are handled correctly."""
|
||||
array_with_files = [file, file]
|
||||
result = truncator._truncate_json_primitives(array_with_files, 1000)
|
||||
|
||||
assert isinstance(result.value, list)
|
||||
assert len(result.value) == 2
|
||||
assert result.value[0] == file
|
||||
assert result.value[1] == file
|
||||
assert result.truncated is False
|
||||
|
||||
def test_truncate_json_primitives_file_type_in_object(self, truncator, file):
|
||||
"""Test File objects in objects are handled correctly."""
|
||||
obj_with_files = {"file1": file, "file2": file}
|
||||
result = truncator._truncate_json_primitives(obj_with_files, 1000)
|
||||
|
||||
assert isinstance(result.value, dict)
|
||||
assert len(result.value) == 2
|
||||
assert result.value["file1"] == file
|
||||
assert result.value["file2"] == file
|
||||
assert result.truncated is False
|
||||
|
||||
|
||||
class TestIntegrationScenarios:
|
||||
"""Test realistic integration scenarios."""
|
||||
|
||||
|
||||
+3
-3
@@ -1229,7 +1229,7 @@ NGINX_SSL_PORT=443
|
||||
# and modify the env vars below accordingly.
|
||||
NGINX_SSL_CERT_FILENAME=dify.crt
|
||||
NGINX_SSL_CERT_KEY_FILENAME=dify.key
|
||||
NGINX_SSL_PROTOCOLS=TLSv1.1 TLSv1.2 TLSv1.3
|
||||
NGINX_SSL_PROTOCOLS=TLSv1.2 TLSv1.3
|
||||
|
||||
# Nginx performance tuning
|
||||
NGINX_WORKER_PROCESSES=auto
|
||||
@@ -1421,7 +1421,7 @@ QUEUE_MONITOR_ALERT_EMAILS=
|
||||
QUEUE_MONITOR_INTERVAL=30
|
||||
|
||||
# Swagger UI configuration
|
||||
SWAGGER_UI_ENABLED=true
|
||||
SWAGGER_UI_ENABLED=false
|
||||
SWAGGER_UI_PATH=/swagger-ui.html
|
||||
|
||||
# Whether to encrypt dataset IDs when exporting DSL files (default: true)
|
||||
@@ -1460,4 +1460,4 @@ ANNOTATION_IMPORT_RATE_LIMIT_PER_HOUR=20
|
||||
ANNOTATION_IMPORT_MAX_CONCURRENT=5
|
||||
|
||||
# The API key of amplitude
|
||||
AMPLITUDE_API_KEY=
|
||||
AMPLITUDE_API_KEY=
|
||||
@@ -414,7 +414,7 @@ services:
|
||||
# and modify the env vars below in .env if HTTPS_ENABLED is true.
|
||||
NGINX_SSL_CERT_FILENAME: ${NGINX_SSL_CERT_FILENAME:-dify.crt}
|
||||
NGINX_SSL_CERT_KEY_FILENAME: ${NGINX_SSL_CERT_KEY_FILENAME:-dify.key}
|
||||
NGINX_SSL_PROTOCOLS: ${NGINX_SSL_PROTOCOLS:-TLSv1.1 TLSv1.2 TLSv1.3}
|
||||
NGINX_SSL_PROTOCOLS: ${NGINX_SSL_PROTOCOLS:-TLSv1.2 TLSv1.3}
|
||||
NGINX_WORKER_PROCESSES: ${NGINX_WORKER_PROCESSES:-auto}
|
||||
NGINX_CLIENT_MAX_BODY_SIZE: ${NGINX_CLIENT_MAX_BODY_SIZE:-100M}
|
||||
NGINX_KEEPALIVE_TIMEOUT: ${NGINX_KEEPALIVE_TIMEOUT:-65}
|
||||
|
||||
@@ -528,7 +528,7 @@ x-shared-env: &shared-api-worker-env
|
||||
NGINX_SSL_PORT: ${NGINX_SSL_PORT:-443}
|
||||
NGINX_SSL_CERT_FILENAME: ${NGINX_SSL_CERT_FILENAME:-dify.crt}
|
||||
NGINX_SSL_CERT_KEY_FILENAME: ${NGINX_SSL_CERT_KEY_FILENAME:-dify.key}
|
||||
NGINX_SSL_PROTOCOLS: ${NGINX_SSL_PROTOCOLS:-TLSv1.1 TLSv1.2 TLSv1.3}
|
||||
NGINX_SSL_PROTOCOLS: ${NGINX_SSL_PROTOCOLS:-TLSv1.2 TLSv1.3}
|
||||
NGINX_WORKER_PROCESSES: ${NGINX_WORKER_PROCESSES:-auto}
|
||||
NGINX_CLIENT_MAX_BODY_SIZE: ${NGINX_CLIENT_MAX_BODY_SIZE:-100M}
|
||||
NGINX_KEEPALIVE_TIMEOUT: ${NGINX_KEEPALIVE_TIMEOUT:-65}
|
||||
@@ -631,7 +631,7 @@ x-shared-env: &shared-api-worker-env
|
||||
QUEUE_MONITOR_THRESHOLD: ${QUEUE_MONITOR_THRESHOLD:-200}
|
||||
QUEUE_MONITOR_ALERT_EMAILS: ${QUEUE_MONITOR_ALERT_EMAILS:-}
|
||||
QUEUE_MONITOR_INTERVAL: ${QUEUE_MONITOR_INTERVAL:-30}
|
||||
SWAGGER_UI_ENABLED: ${SWAGGER_UI_ENABLED:-true}
|
||||
SWAGGER_UI_ENABLED: ${SWAGGER_UI_ENABLED:-false}
|
||||
SWAGGER_UI_PATH: ${SWAGGER_UI_PATH:-/swagger-ui.html}
|
||||
DSL_EXPORT_ENCRYPT_DATASET_ID: ${DSL_EXPORT_ENCRYPT_DATASET_ID:-true}
|
||||
DATASET_MAX_SEGMENTS_PER_REQUEST: ${DATASET_MAX_SEGMENTS_PER_REQUEST:-0}
|
||||
@@ -1071,7 +1071,7 @@ services:
|
||||
# and modify the env vars below in .env if HTTPS_ENABLED is true.
|
||||
NGINX_SSL_CERT_FILENAME: ${NGINX_SSL_CERT_FILENAME:-dify.crt}
|
||||
NGINX_SSL_CERT_KEY_FILENAME: ${NGINX_SSL_CERT_KEY_FILENAME:-dify.key}
|
||||
NGINX_SSL_PROTOCOLS: ${NGINX_SSL_PROTOCOLS:-TLSv1.1 TLSv1.2 TLSv1.3}
|
||||
NGINX_SSL_PROTOCOLS: ${NGINX_SSL_PROTOCOLS:-TLSv1.2 TLSv1.3}
|
||||
NGINX_WORKER_PROCESSES: ${NGINX_WORKER_PROCESSES:-auto}
|
||||
NGINX_CLIENT_MAX_BODY_SIZE: ${NGINX_CLIENT_MAX_BODY_SIZE:-100M}
|
||||
NGINX_KEEPALIVE_TIMEOUT: ${NGINX_KEEPALIVE_TIMEOUT:-65}
|
||||
|
||||
@@ -19,7 +19,13 @@
|
||||
*/
|
||||
|
||||
export const useTranslation = () => ({
|
||||
t: (key: string) => key,
|
||||
t: (key: string, options?: Record<string, unknown>) => {
|
||||
if (options?.returnObjects)
|
||||
return [`${key}-feature-1`, `${key}-feature-2`]
|
||||
if (options)
|
||||
return `${key}:${JSON.stringify(options)}`
|
||||
return key
|
||||
},
|
||||
i18n: {
|
||||
language: 'en',
|
||||
changeLanguage: jest.fn(),
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import React from 'react'
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import EditItem, { EditItemType } from './index'
|
||||
|
||||
jest.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string) => key,
|
||||
}),
|
||||
}))
|
||||
|
||||
describe('AddAnnotationModal/EditItem', () => {
|
||||
test('should render query inputs with user avatar and placeholder strings', () => {
|
||||
render(
|
||||
<EditItem
|
||||
type={EditItemType.Query}
|
||||
content="Why?"
|
||||
onChange={jest.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('appAnnotation.addModal.queryName')).toBeInTheDocument()
|
||||
expect(screen.getByPlaceholderText('appAnnotation.addModal.queryPlaceholder')).toBeInTheDocument()
|
||||
expect(screen.getByText('Why?')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
test('should render answer name and placeholder text', () => {
|
||||
render(
|
||||
<EditItem
|
||||
type={EditItemType.Answer}
|
||||
content="Existing answer"
|
||||
onChange={jest.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('appAnnotation.addModal.answerName')).toBeInTheDocument()
|
||||
expect(screen.getByPlaceholderText('appAnnotation.addModal.answerPlaceholder')).toBeInTheDocument()
|
||||
expect(screen.getByDisplayValue('Existing answer')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
test('should propagate changes when answer content updates', () => {
|
||||
const handleChange = jest.fn()
|
||||
render(
|
||||
<EditItem
|
||||
type={EditItemType.Answer}
|
||||
content=""
|
||||
onChange={handleChange}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.change(screen.getByPlaceholderText('appAnnotation.addModal.answerPlaceholder'), { target: { value: 'Because' } })
|
||||
expect(handleChange).toHaveBeenCalledWith('Because')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,155 @@
|
||||
import React from 'react'
|
||||
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import AddAnnotationModal from './index'
|
||||
import { useProviderContext } from '@/context/provider-context'
|
||||
|
||||
jest.mock('@/context/provider-context', () => ({
|
||||
useProviderContext: jest.fn(),
|
||||
}))
|
||||
|
||||
const mockToastNotify = jest.fn()
|
||||
jest.mock('@/app/components/base/toast', () => ({
|
||||
__esModule: true,
|
||||
default: {
|
||||
notify: jest.fn(args => mockToastNotify(args)),
|
||||
},
|
||||
}))
|
||||
|
||||
jest.mock('@/app/components/billing/annotation-full', () => () => <div data-testid="annotation-full" />)
|
||||
|
||||
const mockUseProviderContext = useProviderContext as jest.Mock
|
||||
|
||||
const getProviderContext = ({ usage = 0, total = 10, enableBilling = false } = {}) => ({
|
||||
plan: {
|
||||
usage: { annotatedResponse: usage },
|
||||
total: { annotatedResponse: total },
|
||||
},
|
||||
enableBilling,
|
||||
})
|
||||
|
||||
describe('AddAnnotationModal', () => {
|
||||
const baseProps = {
|
||||
isShow: true,
|
||||
onHide: jest.fn(),
|
||||
onAdd: jest.fn(),
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks()
|
||||
mockUseProviderContext.mockReturnValue(getProviderContext())
|
||||
})
|
||||
|
||||
const typeQuestion = (value: string) => {
|
||||
fireEvent.change(screen.getByPlaceholderText('appAnnotation.addModal.queryPlaceholder'), {
|
||||
target: { value },
|
||||
})
|
||||
}
|
||||
|
||||
const typeAnswer = (value: string) => {
|
||||
fireEvent.change(screen.getByPlaceholderText('appAnnotation.addModal.answerPlaceholder'), {
|
||||
target: { value },
|
||||
})
|
||||
}
|
||||
|
||||
test('should render modal title when drawer is visible', () => {
|
||||
render(<AddAnnotationModal {...baseProps} />)
|
||||
|
||||
expect(screen.getByText('appAnnotation.addModal.title')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
test('should capture query input text when typing', () => {
|
||||
render(<AddAnnotationModal {...baseProps} />)
|
||||
typeQuestion('Sample question')
|
||||
expect(screen.getByPlaceholderText('appAnnotation.addModal.queryPlaceholder')).toHaveValue('Sample question')
|
||||
})
|
||||
|
||||
test('should capture answer input text when typing', () => {
|
||||
render(<AddAnnotationModal {...baseProps} />)
|
||||
typeAnswer('Sample answer')
|
||||
expect(screen.getByPlaceholderText('appAnnotation.addModal.answerPlaceholder')).toHaveValue('Sample answer')
|
||||
})
|
||||
|
||||
test('should show annotation full notice and disable submit when quota exceeded', () => {
|
||||
mockUseProviderContext.mockReturnValue(getProviderContext({ usage: 10, total: 10, enableBilling: true }))
|
||||
render(<AddAnnotationModal {...baseProps} />)
|
||||
|
||||
expect(screen.getByTestId('annotation-full')).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'common.operation.add' })).toBeDisabled()
|
||||
})
|
||||
|
||||
test('should call onAdd with form values when create next enabled', async () => {
|
||||
const onAdd = jest.fn().mockResolvedValue(undefined)
|
||||
render(<AddAnnotationModal {...baseProps} onAdd={onAdd} />)
|
||||
|
||||
typeQuestion('Question value')
|
||||
typeAnswer('Answer value')
|
||||
fireEvent.click(screen.getByTestId('checkbox-create-next-checkbox'))
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByRole('button', { name: 'common.operation.add' }))
|
||||
})
|
||||
|
||||
expect(onAdd).toHaveBeenCalledWith({ question: 'Question value', answer: 'Answer value' })
|
||||
})
|
||||
|
||||
test('should reset fields after saving when create next enabled', async () => {
|
||||
const onAdd = jest.fn().mockResolvedValue(undefined)
|
||||
render(<AddAnnotationModal {...baseProps} onAdd={onAdd} />)
|
||||
|
||||
typeQuestion('Question value')
|
||||
typeAnswer('Answer value')
|
||||
const createNextToggle = screen.getByText('appAnnotation.addModal.createNext').previousElementSibling as HTMLElement
|
||||
fireEvent.click(createNextToggle)
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByRole('button', { name: 'common.operation.add' }))
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByPlaceholderText('appAnnotation.addModal.queryPlaceholder')).toHaveValue('')
|
||||
expect(screen.getByPlaceholderText('appAnnotation.addModal.answerPlaceholder')).toHaveValue('')
|
||||
})
|
||||
})
|
||||
|
||||
test('should show toast when validation fails for missing question', () => {
|
||||
render(<AddAnnotationModal {...baseProps} />)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'common.operation.add' }))
|
||||
expect(mockToastNotify).toHaveBeenCalledWith(expect.objectContaining({
|
||||
type: 'error',
|
||||
message: 'appAnnotation.errorMessage.queryRequired',
|
||||
}))
|
||||
})
|
||||
|
||||
test('should show toast when validation fails for missing answer', () => {
|
||||
render(<AddAnnotationModal {...baseProps} />)
|
||||
typeQuestion('Filled question')
|
||||
fireEvent.click(screen.getByRole('button', { name: 'common.operation.add' }))
|
||||
|
||||
expect(mockToastNotify).toHaveBeenCalledWith(expect.objectContaining({
|
||||
type: 'error',
|
||||
message: 'appAnnotation.errorMessage.answerRequired',
|
||||
}))
|
||||
})
|
||||
|
||||
test('should close modal when save completes and create next unchecked', async () => {
|
||||
const onAdd = jest.fn().mockResolvedValue(undefined)
|
||||
render(<AddAnnotationModal {...baseProps} onAdd={onAdd} />)
|
||||
|
||||
typeQuestion('Q')
|
||||
typeAnswer('A')
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByRole('button', { name: 'common.operation.add' }))
|
||||
})
|
||||
|
||||
expect(baseProps.onHide).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
test('should allow cancel button to close the drawer', () => {
|
||||
render(<AddAnnotationModal {...baseProps} />)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'common.operation.cancel' }))
|
||||
expect(baseProps.onHide).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -101,7 +101,7 @@ const AddAnnotationModal: FC<Props> = ({
|
||||
<div
|
||||
className='flex items-center space-x-2'
|
||||
>
|
||||
<Checkbox checked={isCreateNext} onCheck={() => setIsCreateNext(!isCreateNext)} />
|
||||
<Checkbox id='create-next-checkbox' checked={isCreateNext} onCheck={() => setIsCreateNext(!isCreateNext)} />
|
||||
<div>{t('appAnnotation.addModal.createNext')}</div>
|
||||
</div>
|
||||
<div className='mt-2 flex space-x-2'>
|
||||
|
||||
@@ -0,0 +1,397 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import EditItem, { EditItemType, EditTitle } from './index'
|
||||
|
||||
describe('EditTitle', () => {
|
||||
it('should render title content correctly', () => {
|
||||
// Arrange
|
||||
const props = { title: 'Test Title' }
|
||||
|
||||
// Act
|
||||
render(<EditTitle {...props} />)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByText(/test title/i)).toBeInTheDocument()
|
||||
// Should contain edit icon (svg element)
|
||||
expect(document.querySelector('svg')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should apply custom className when provided', () => {
|
||||
// Arrange
|
||||
const props = {
|
||||
title: 'Test Title',
|
||||
className: 'custom-class',
|
||||
}
|
||||
|
||||
// Act
|
||||
const { container } = render(<EditTitle {...props} />)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByText(/test title/i)).toBeInTheDocument()
|
||||
expect(container.querySelector('.custom-class')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('EditItem', () => {
|
||||
const defaultProps = {
|
||||
type: EditItemType.Query,
|
||||
content: 'Test content',
|
||||
onSave: jest.fn(),
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks()
|
||||
})
|
||||
|
||||
// Rendering tests (REQUIRED)
|
||||
describe('Rendering', () => {
|
||||
it('should render content correctly', () => {
|
||||
// Arrange
|
||||
const props = { ...defaultProps }
|
||||
|
||||
// Act
|
||||
render(<EditItem {...props} />)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByText(/test content/i)).toBeInTheDocument()
|
||||
// Should show item name (query or answer)
|
||||
expect(screen.getByText('appAnnotation.editModal.queryName')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render different item types correctly', () => {
|
||||
// Arrange
|
||||
const props = {
|
||||
...defaultProps,
|
||||
type: EditItemType.Answer,
|
||||
content: 'Answer content',
|
||||
}
|
||||
|
||||
// Act
|
||||
render(<EditItem {...props} />)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByText(/answer content/i)).toBeInTheDocument()
|
||||
expect(screen.getByText('appAnnotation.editModal.answerName')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show edit controls when not readonly', () => {
|
||||
// Arrange
|
||||
const props = { ...defaultProps }
|
||||
|
||||
// Act
|
||||
render(<EditItem {...props} />)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByText('common.operation.edit')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should hide edit controls when readonly', () => {
|
||||
// Arrange
|
||||
const props = {
|
||||
...defaultProps,
|
||||
readonly: true,
|
||||
}
|
||||
|
||||
// Act
|
||||
render(<EditItem {...props} />)
|
||||
|
||||
// Assert
|
||||
expect(screen.queryByText('common.operation.edit')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// Props tests (REQUIRED)
|
||||
describe('Props', () => {
|
||||
it('should respect readonly prop for edit functionality', () => {
|
||||
// Arrange
|
||||
const props = {
|
||||
...defaultProps,
|
||||
readonly: true,
|
||||
}
|
||||
|
||||
// Act
|
||||
render(<EditItem {...props} />)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByText(/test content/i)).toBeInTheDocument()
|
||||
expect(screen.queryByText('common.operation.edit')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should display provided content', () => {
|
||||
// Arrange
|
||||
const props = {
|
||||
...defaultProps,
|
||||
content: 'Custom content for testing',
|
||||
}
|
||||
|
||||
// Act
|
||||
render(<EditItem {...props} />)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByText(/custom content for testing/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render appropriate content based on type', () => {
|
||||
// Arrange
|
||||
const props = {
|
||||
...defaultProps,
|
||||
type: EditItemType.Query,
|
||||
content: 'Question content',
|
||||
}
|
||||
|
||||
// Act
|
||||
render(<EditItem {...props} />)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByText(/question content/i)).toBeInTheDocument()
|
||||
expect(screen.getByText('appAnnotation.editModal.queryName')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// User Interactions
|
||||
describe('User Interactions', () => {
|
||||
it('should activate edit mode when edit button is clicked', async () => {
|
||||
// Arrange
|
||||
const props = { ...defaultProps }
|
||||
const user = userEvent.setup()
|
||||
|
||||
// Act
|
||||
render(<EditItem {...props} />)
|
||||
await user.click(screen.getByText('common.operation.edit'))
|
||||
|
||||
// Assert
|
||||
expect(screen.getByRole('textbox')).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'common.operation.save' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'common.operation.cancel' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should save new content when save button is clicked', async () => {
|
||||
// Arrange
|
||||
const mockSave = jest.fn().mockResolvedValue(undefined)
|
||||
const props = {
|
||||
...defaultProps,
|
||||
onSave: mockSave,
|
||||
}
|
||||
const user = userEvent.setup()
|
||||
|
||||
// Act
|
||||
render(<EditItem {...props} />)
|
||||
await user.click(screen.getByText('common.operation.edit'))
|
||||
|
||||
// Type new content
|
||||
const textarea = screen.getByRole('textbox')
|
||||
await user.clear(textarea)
|
||||
await user.type(textarea, 'Updated content')
|
||||
|
||||
// Save
|
||||
await user.click(screen.getByRole('button', { name: 'common.operation.save' }))
|
||||
|
||||
// Assert
|
||||
expect(mockSave).toHaveBeenCalledWith('Updated content')
|
||||
})
|
||||
|
||||
it('should exit edit mode when cancel button is clicked', async () => {
|
||||
// Arrange
|
||||
const props = { ...defaultProps }
|
||||
const user = userEvent.setup()
|
||||
|
||||
// Act
|
||||
render(<EditItem {...props} />)
|
||||
await user.click(screen.getByText('common.operation.edit'))
|
||||
await user.click(screen.getByRole('button', { name: 'common.operation.cancel' }))
|
||||
|
||||
// Assert
|
||||
expect(screen.queryByRole('textbox')).not.toBeInTheDocument()
|
||||
expect(screen.getByText(/test content/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show content preview while typing', async () => {
|
||||
// Arrange
|
||||
const props = { ...defaultProps }
|
||||
const user = userEvent.setup()
|
||||
|
||||
// Act
|
||||
render(<EditItem {...props} />)
|
||||
await user.click(screen.getByText('common.operation.edit'))
|
||||
|
||||
const textarea = screen.getByRole('textbox')
|
||||
await user.type(textarea, 'New content')
|
||||
|
||||
// Assert
|
||||
expect(screen.getByText(/new content/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should call onSave with correct content when saving', async () => {
|
||||
// Arrange
|
||||
const mockSave = jest.fn().mockResolvedValue(undefined)
|
||||
const props = {
|
||||
...defaultProps,
|
||||
onSave: mockSave,
|
||||
}
|
||||
const user = userEvent.setup()
|
||||
|
||||
// Act
|
||||
render(<EditItem {...props} />)
|
||||
await user.click(screen.getByText('common.operation.edit'))
|
||||
|
||||
const textarea = screen.getByRole('textbox')
|
||||
await user.clear(textarea)
|
||||
await user.type(textarea, 'Test save content')
|
||||
|
||||
// Save
|
||||
await user.click(screen.getByRole('button', { name: 'common.operation.save' }))
|
||||
|
||||
// Assert
|
||||
expect(mockSave).toHaveBeenCalledWith('Test save content')
|
||||
})
|
||||
|
||||
it('should show delete option when content changes', async () => {
|
||||
// Arrange
|
||||
const mockSave = jest.fn().mockResolvedValue(undefined)
|
||||
const props = {
|
||||
...defaultProps,
|
||||
onSave: mockSave,
|
||||
}
|
||||
const user = userEvent.setup()
|
||||
|
||||
// Act
|
||||
render(<EditItem {...props} />)
|
||||
|
||||
// Enter edit mode and change content
|
||||
await user.click(screen.getByText('common.operation.edit'))
|
||||
const textarea = screen.getByRole('textbox')
|
||||
await user.clear(textarea)
|
||||
await user.type(textarea, 'Modified content')
|
||||
|
||||
// Save to trigger content change
|
||||
await user.click(screen.getByRole('button', { name: 'common.operation.save' }))
|
||||
|
||||
// Assert
|
||||
expect(mockSave).toHaveBeenCalledWith('Modified content')
|
||||
})
|
||||
|
||||
it('should handle keyboard interactions in edit mode', async () => {
|
||||
// Arrange
|
||||
const props = { ...defaultProps }
|
||||
const user = userEvent.setup()
|
||||
|
||||
// Act
|
||||
render(<EditItem {...props} />)
|
||||
await user.click(screen.getByText('common.operation.edit'))
|
||||
|
||||
const textarea = screen.getByRole('textbox')
|
||||
|
||||
// Test typing
|
||||
await user.type(textarea, 'Keyboard test')
|
||||
|
||||
// Assert
|
||||
expect(textarea).toHaveValue('Keyboard test')
|
||||
expect(screen.getByText(/keyboard test/i)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// State Management
|
||||
describe('State Management', () => {
|
||||
it('should reset newContent when content prop changes', async () => {
|
||||
// Arrange
|
||||
const { rerender } = render(<EditItem {...defaultProps} />)
|
||||
|
||||
// Act - Enter edit mode and type something
|
||||
const user = userEvent.setup()
|
||||
await user.click(screen.getByText('common.operation.edit'))
|
||||
const textarea = screen.getByRole('textbox')
|
||||
await user.clear(textarea)
|
||||
await user.type(textarea, 'New content')
|
||||
|
||||
// Rerender with new content prop
|
||||
rerender(<EditItem {...defaultProps} content="Updated content" />)
|
||||
|
||||
// Assert - Textarea value should be reset due to useEffect
|
||||
expect(textarea).toHaveValue('')
|
||||
})
|
||||
|
||||
it('should preserve edit state across content changes', async () => {
|
||||
// Arrange
|
||||
const { rerender } = render(<EditItem {...defaultProps} />)
|
||||
const user = userEvent.setup()
|
||||
|
||||
// Act - Enter edit mode
|
||||
await user.click(screen.getByText('common.operation.edit'))
|
||||
|
||||
// Rerender with new content
|
||||
rerender(<EditItem {...defaultProps} content="Updated content" />)
|
||||
|
||||
// Assert - Should still be in edit mode
|
||||
expect(screen.getByRole('textbox')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// Edge Cases (REQUIRED)
|
||||
describe('Edge Cases', () => {
|
||||
it('should handle empty content', () => {
|
||||
// Arrange
|
||||
const props = {
|
||||
...defaultProps,
|
||||
content: '',
|
||||
}
|
||||
|
||||
// Act
|
||||
const { container } = render(<EditItem {...props} />)
|
||||
|
||||
// Assert - Should render without crashing
|
||||
// Check that the component renders properly with empty content
|
||||
expect(container.querySelector('.grow')).toBeInTheDocument()
|
||||
// Should still show edit button
|
||||
expect(screen.getByText('common.operation.edit')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle very long content', () => {
|
||||
// Arrange
|
||||
const longContent = 'A'.repeat(1000)
|
||||
const props = {
|
||||
...defaultProps,
|
||||
content: longContent,
|
||||
}
|
||||
|
||||
// Act
|
||||
render(<EditItem {...props} />)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByText(longContent)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle content with special characters', () => {
|
||||
// Arrange
|
||||
const specialContent = 'Content with & < > " \' characters'
|
||||
const props = {
|
||||
...defaultProps,
|
||||
content: specialContent,
|
||||
}
|
||||
|
||||
// Act
|
||||
render(<EditItem {...props} />)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByText(specialContent)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle rapid edit/cancel operations', async () => {
|
||||
// Arrange
|
||||
const props = { ...defaultProps }
|
||||
const user = userEvent.setup()
|
||||
|
||||
// Act
|
||||
render(<EditItem {...props} />)
|
||||
|
||||
// Rapid edit/cancel operations
|
||||
await user.click(screen.getByText('common.operation.edit'))
|
||||
await user.click(screen.getByText('common.operation.cancel'))
|
||||
await user.click(screen.getByText('common.operation.edit'))
|
||||
await user.click(screen.getByText('common.operation.cancel'))
|
||||
|
||||
// Assert
|
||||
expect(screen.queryByRole('textbox')).not.toBeInTheDocument()
|
||||
expect(screen.getByText('Test content')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,408 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import Toast, { type IToastProps, type ToastHandle } from '@/app/components/base/toast'
|
||||
import EditAnnotationModal from './index'
|
||||
|
||||
// Mock only external dependencies
|
||||
jest.mock('@/service/annotation', () => ({
|
||||
addAnnotation: jest.fn(),
|
||||
editAnnotation: jest.fn(),
|
||||
}))
|
||||
|
||||
jest.mock('@/context/provider-context', () => ({
|
||||
useProviderContext: () => ({
|
||||
plan: {
|
||||
usage: { annotatedResponse: 5 },
|
||||
total: { annotatedResponse: 10 },
|
||||
},
|
||||
enableBilling: true,
|
||||
}),
|
||||
}))
|
||||
|
||||
jest.mock('@/hooks/use-timestamp', () => ({
|
||||
__esModule: true,
|
||||
default: () => ({
|
||||
formatTime: () => '2023-12-01 10:30:00',
|
||||
}),
|
||||
}))
|
||||
|
||||
// Note: i18n is automatically mocked by Jest via __mocks__/react-i18next.ts
|
||||
|
||||
jest.mock('@/app/components/billing/annotation-full', () => ({
|
||||
__esModule: true,
|
||||
default: () => <div data-testid="annotation-full" />,
|
||||
}))
|
||||
|
||||
type ToastNotifyProps = Pick<IToastProps, 'type' | 'size' | 'message' | 'duration' | 'className' | 'customComponent' | 'onClose'>
|
||||
type ToastWithNotify = typeof Toast & { notify: (props: ToastNotifyProps) => ToastHandle }
|
||||
const toastWithNotify = Toast as unknown as ToastWithNotify
|
||||
const toastNotifySpy = jest.spyOn(toastWithNotify, 'notify').mockReturnValue({ clear: jest.fn() })
|
||||
|
||||
const { addAnnotation: mockAddAnnotation, editAnnotation: mockEditAnnotation } = jest.requireMock('@/service/annotation') as {
|
||||
addAnnotation: jest.Mock
|
||||
editAnnotation: jest.Mock
|
||||
}
|
||||
|
||||
describe('EditAnnotationModal', () => {
|
||||
const defaultProps = {
|
||||
isShow: true,
|
||||
onHide: jest.fn(),
|
||||
appId: 'test-app-id',
|
||||
query: 'Test query',
|
||||
answer: 'Test answer',
|
||||
onEdited: jest.fn(),
|
||||
onAdded: jest.fn(),
|
||||
onRemove: jest.fn(),
|
||||
}
|
||||
|
||||
afterAll(() => {
|
||||
toastNotifySpy.mockRestore()
|
||||
})
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks()
|
||||
mockAddAnnotation.mockResolvedValue({
|
||||
id: 'test-id',
|
||||
account: { name: 'Test User' },
|
||||
})
|
||||
mockEditAnnotation.mockResolvedValue({})
|
||||
})
|
||||
|
||||
// Rendering tests (REQUIRED)
|
||||
describe('Rendering', () => {
|
||||
it('should render modal when isShow is true', () => {
|
||||
// Arrange
|
||||
const props = { ...defaultProps }
|
||||
|
||||
// Act
|
||||
render(<EditAnnotationModal {...props} />)
|
||||
|
||||
// Assert - Check for modal title as it appears in the mock
|
||||
expect(screen.getByText('appAnnotation.editModal.title')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not render modal when isShow is false', () => {
|
||||
// Arrange
|
||||
const props = { ...defaultProps, isShow: false }
|
||||
|
||||
// Act
|
||||
render(<EditAnnotationModal {...props} />)
|
||||
|
||||
// Assert
|
||||
expect(screen.queryByText('appAnnotation.editModal.title')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should display query and answer sections', () => {
|
||||
// Arrange
|
||||
const props = { ...defaultProps }
|
||||
|
||||
// Act
|
||||
render(<EditAnnotationModal {...props} />)
|
||||
|
||||
// Assert - Look for query and answer content
|
||||
expect(screen.getByText('Test query')).toBeInTheDocument()
|
||||
expect(screen.getByText('Test answer')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// Props tests (REQUIRED)
|
||||
describe('Props', () => {
|
||||
it('should handle different query and answer content', () => {
|
||||
// Arrange
|
||||
const props = {
|
||||
...defaultProps,
|
||||
query: 'Custom query content',
|
||||
answer: 'Custom answer content',
|
||||
}
|
||||
|
||||
// Act
|
||||
render(<EditAnnotationModal {...props} />)
|
||||
|
||||
// Assert - Check content is displayed
|
||||
expect(screen.getByText('Custom query content')).toBeInTheDocument()
|
||||
expect(screen.getByText('Custom answer content')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show remove option when annotationId is provided', () => {
|
||||
// Arrange
|
||||
const props = {
|
||||
...defaultProps,
|
||||
annotationId: 'test-annotation-id',
|
||||
}
|
||||
|
||||
// Act
|
||||
render(<EditAnnotationModal {...props} />)
|
||||
|
||||
// Assert - Remove option should be present (using pattern)
|
||||
expect(screen.getByText('appAnnotation.editModal.removeThisCache')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// User Interactions
|
||||
describe('User Interactions', () => {
|
||||
it('should enable editing for query and answer sections', () => {
|
||||
// Arrange
|
||||
const props = { ...defaultProps }
|
||||
|
||||
// Act
|
||||
render(<EditAnnotationModal {...props} />)
|
||||
|
||||
// Assert - Edit links should be visible (using text content)
|
||||
const editLinks = screen.getAllByText(/common\.operation\.edit/i)
|
||||
expect(editLinks).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('should show remove option when annotationId is provided', () => {
|
||||
// Arrange
|
||||
const props = {
|
||||
...defaultProps,
|
||||
annotationId: 'test-annotation-id',
|
||||
}
|
||||
|
||||
// Act
|
||||
render(<EditAnnotationModal {...props} />)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByText('appAnnotation.editModal.removeThisCache')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should save content when edited', async () => {
|
||||
// Arrange
|
||||
const mockOnAdded = jest.fn()
|
||||
const props = {
|
||||
...defaultProps,
|
||||
onAdded: mockOnAdded,
|
||||
}
|
||||
const user = userEvent.setup()
|
||||
|
||||
// Mock API response
|
||||
mockAddAnnotation.mockResolvedValueOnce({
|
||||
id: 'test-annotation-id',
|
||||
account: { name: 'Test User' },
|
||||
})
|
||||
|
||||
// Act
|
||||
render(<EditAnnotationModal {...props} />)
|
||||
|
||||
// Find and click edit link for query
|
||||
const editLinks = screen.getAllByText(/common\.operation\.edit/i)
|
||||
await user.click(editLinks[0])
|
||||
|
||||
// Find textarea and enter new content
|
||||
const textarea = screen.getByRole('textbox')
|
||||
await user.clear(textarea)
|
||||
await user.type(textarea, 'New query content')
|
||||
|
||||
// Click save button
|
||||
const saveButton = screen.getByRole('button', { name: 'common.operation.save' })
|
||||
await user.click(saveButton)
|
||||
|
||||
// Assert
|
||||
expect(mockAddAnnotation).toHaveBeenCalledWith('test-app-id', {
|
||||
question: 'New query content',
|
||||
answer: 'Test answer',
|
||||
message_id: undefined,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// API Calls
|
||||
describe('API Calls', () => {
|
||||
it('should call addAnnotation when saving new annotation', async () => {
|
||||
// Arrange
|
||||
const mockOnAdded = jest.fn()
|
||||
const props = {
|
||||
...defaultProps,
|
||||
onAdded: mockOnAdded,
|
||||
}
|
||||
const user = userEvent.setup()
|
||||
|
||||
// Mock the API response
|
||||
mockAddAnnotation.mockResolvedValueOnce({
|
||||
id: 'test-annotation-id',
|
||||
account: { name: 'Test User' },
|
||||
})
|
||||
|
||||
// Act
|
||||
render(<EditAnnotationModal {...props} />)
|
||||
|
||||
// Edit query content
|
||||
const editLinks = screen.getAllByText(/common\.operation\.edit/i)
|
||||
await user.click(editLinks[0])
|
||||
|
||||
const textarea = screen.getByRole('textbox')
|
||||
await user.clear(textarea)
|
||||
await user.type(textarea, 'Updated query')
|
||||
|
||||
const saveButton = screen.getByRole('button', { name: 'common.operation.save' })
|
||||
await user.click(saveButton)
|
||||
|
||||
// Assert
|
||||
expect(mockAddAnnotation).toHaveBeenCalledWith('test-app-id', {
|
||||
question: 'Updated query',
|
||||
answer: 'Test answer',
|
||||
message_id: undefined,
|
||||
})
|
||||
})
|
||||
|
||||
it('should call editAnnotation when updating existing annotation', async () => {
|
||||
// Arrange
|
||||
const mockOnEdited = jest.fn()
|
||||
const props = {
|
||||
...defaultProps,
|
||||
annotationId: 'test-annotation-id',
|
||||
messageId: 'test-message-id',
|
||||
onEdited: mockOnEdited,
|
||||
}
|
||||
const user = userEvent.setup()
|
||||
|
||||
// Act
|
||||
render(<EditAnnotationModal {...props} />)
|
||||
|
||||
// Edit query content
|
||||
const editLinks = screen.getAllByText(/common\.operation\.edit/i)
|
||||
await user.click(editLinks[0])
|
||||
|
||||
const textarea = screen.getByRole('textbox')
|
||||
await user.clear(textarea)
|
||||
await user.type(textarea, 'Modified query')
|
||||
|
||||
const saveButton = screen.getByRole('button', { name: 'common.operation.save' })
|
||||
await user.click(saveButton)
|
||||
|
||||
// Assert
|
||||
expect(mockEditAnnotation).toHaveBeenCalledWith(
|
||||
'test-app-id',
|
||||
'test-annotation-id',
|
||||
{
|
||||
message_id: 'test-message-id',
|
||||
question: 'Modified query',
|
||||
answer: 'Test answer',
|
||||
},
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
// State Management
|
||||
describe('State Management', () => {
|
||||
it('should initialize with closed confirm modal', () => {
|
||||
// Arrange
|
||||
const props = { ...defaultProps }
|
||||
|
||||
// Act
|
||||
render(<EditAnnotationModal {...props} />)
|
||||
|
||||
// Assert - Confirm dialog should not be visible initially
|
||||
expect(screen.queryByText('appDebug.feature.annotation.removeConfirm')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show confirm modal when remove is clicked', async () => {
|
||||
// Arrange
|
||||
const props = {
|
||||
...defaultProps,
|
||||
annotationId: 'test-annotation-id',
|
||||
}
|
||||
const user = userEvent.setup()
|
||||
|
||||
// Act
|
||||
render(<EditAnnotationModal {...props} />)
|
||||
await user.click(screen.getByText('appAnnotation.editModal.removeThisCache'))
|
||||
|
||||
// Assert - Confirmation dialog should appear
|
||||
expect(screen.getByText('appDebug.feature.annotation.removeConfirm')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should call onRemove when removal is confirmed', async () => {
|
||||
// Arrange
|
||||
const mockOnRemove = jest.fn()
|
||||
const props = {
|
||||
...defaultProps,
|
||||
annotationId: 'test-annotation-id',
|
||||
onRemove: mockOnRemove,
|
||||
}
|
||||
const user = userEvent.setup()
|
||||
|
||||
// Act
|
||||
render(<EditAnnotationModal {...props} />)
|
||||
|
||||
// Click remove
|
||||
await user.click(screen.getByText('appAnnotation.editModal.removeThisCache'))
|
||||
|
||||
// Click confirm
|
||||
const confirmButton = screen.getByRole('button', { name: 'common.operation.confirm' })
|
||||
await user.click(confirmButton)
|
||||
|
||||
// Assert
|
||||
expect(mockOnRemove).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
// Edge Cases (REQUIRED)
|
||||
describe('Edge Cases', () => {
|
||||
it('should handle empty query and answer', () => {
|
||||
// Arrange
|
||||
const props = {
|
||||
...defaultProps,
|
||||
query: '',
|
||||
answer: '',
|
||||
}
|
||||
|
||||
// Act
|
||||
render(<EditAnnotationModal {...props} />)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByText('appAnnotation.editModal.title')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle very long content', () => {
|
||||
// Arrange
|
||||
const longQuery = 'Q'.repeat(1000)
|
||||
const longAnswer = 'A'.repeat(1000)
|
||||
const props = {
|
||||
...defaultProps,
|
||||
query: longQuery,
|
||||
answer: longAnswer,
|
||||
}
|
||||
|
||||
// Act
|
||||
render(<EditAnnotationModal {...props} />)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByText(longQuery)).toBeInTheDocument()
|
||||
expect(screen.getByText(longAnswer)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle special characters in content', () => {
|
||||
// Arrange
|
||||
const specialQuery = 'Query with & < > " \' characters'
|
||||
const specialAnswer = 'Answer with & < > " \' characters'
|
||||
const props = {
|
||||
...defaultProps,
|
||||
query: specialQuery,
|
||||
answer: specialAnswer,
|
||||
}
|
||||
|
||||
// Act
|
||||
render(<EditAnnotationModal {...props} />)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByText(specialQuery)).toBeInTheDocument()
|
||||
expect(screen.getByText(specialAnswer)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle onlyEditResponse prop', () => {
|
||||
// Arrange
|
||||
const props = {
|
||||
...defaultProps,
|
||||
onlyEditResponse: true,
|
||||
}
|
||||
|
||||
// Act
|
||||
render(<EditAnnotationModal {...props} />)
|
||||
|
||||
// Assert - Query should be readonly, answer should be editable
|
||||
const editLinks = screen.queryAllByText(/common\.operation\.edit/i)
|
||||
expect(editLinks).toHaveLength(1) // Only answer should have edit button
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,22 @@
|
||||
import React from 'react'
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import CannotQueryDataset from './cannot-query-dataset'
|
||||
|
||||
describe('CannotQueryDataset WarningMask', () => {
|
||||
test('should render dataset warning copy and action button', () => {
|
||||
const onConfirm = jest.fn()
|
||||
render(<CannotQueryDataset onConfirm={onConfirm} />)
|
||||
|
||||
expect(screen.getByText('appDebug.feature.dataSet.queryVariable.unableToQueryDataSet')).toBeInTheDocument()
|
||||
expect(screen.getByText('appDebug.feature.dataSet.queryVariable.unableToQueryDataSetTip')).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'appDebug.feature.dataSet.queryVariable.ok' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
test('should invoke onConfirm when OK button clicked', () => {
|
||||
const onConfirm = jest.fn()
|
||||
render(<CannotQueryDataset onConfirm={onConfirm} />)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'appDebug.feature.dataSet.queryVariable.ok' }))
|
||||
expect(onConfirm).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,39 @@
|
||||
import React from 'react'
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import FormattingChanged from './formatting-changed'
|
||||
|
||||
describe('FormattingChanged WarningMask', () => {
|
||||
test('should display translation text and both actions', () => {
|
||||
const onConfirm = jest.fn()
|
||||
const onCancel = jest.fn()
|
||||
|
||||
render(
|
||||
<FormattingChanged
|
||||
onConfirm={onConfirm}
|
||||
onCancel={onCancel}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('appDebug.formattingChangedTitle')).toBeInTheDocument()
|
||||
expect(screen.getByText('appDebug.formattingChangedText')).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'common.operation.cancel' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: /common\.operation\.refresh/ })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
test('should call callbacks when buttons are clicked', () => {
|
||||
const onConfirm = jest.fn()
|
||||
const onCancel = jest.fn()
|
||||
render(
|
||||
<FormattingChanged
|
||||
onConfirm={onConfirm}
|
||||
onCancel={onCancel}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /common\.operation\.refresh/ }))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'common.operation.cancel' }))
|
||||
|
||||
expect(onConfirm).toHaveBeenCalledTimes(1)
|
||||
expect(onCancel).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,26 @@
|
||||
import React from 'react'
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import HasNotSetAPI from './has-not-set-api'
|
||||
|
||||
describe('HasNotSetAPI WarningMask', () => {
|
||||
test('should show default title when trial not finished', () => {
|
||||
render(<HasNotSetAPI isTrailFinished={false} onSetting={jest.fn()} />)
|
||||
|
||||
expect(screen.getByText('appDebug.notSetAPIKey.title')).toBeInTheDocument()
|
||||
expect(screen.getByText('appDebug.notSetAPIKey.description')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
test('should show trail finished title when flag is true', () => {
|
||||
render(<HasNotSetAPI isTrailFinished onSetting={jest.fn()} />)
|
||||
|
||||
expect(screen.getByText('appDebug.notSetAPIKey.trailFinished')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
test('should call onSetting when primary button clicked', () => {
|
||||
const onSetting = jest.fn()
|
||||
render(<HasNotSetAPI isTrailFinished={false} onSetting={onSetting} />)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'appDebug.notSetAPIKey.settingBtn' }))
|
||||
expect(onSetting).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,25 @@
|
||||
import React from 'react'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import WarningMask from './index'
|
||||
|
||||
describe('WarningMask', () => {
|
||||
// Rendering of title, description, and footer content
|
||||
describe('Rendering', () => {
|
||||
test('should display provided title, description, and footer node', () => {
|
||||
const footer = <button type="button">Retry</button>
|
||||
// Arrange
|
||||
render(
|
||||
<WarningMask
|
||||
title="Access Restricted"
|
||||
description="Only workspace owners may modify this section."
|
||||
footer={footer}
|
||||
/>,
|
||||
)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByText('Access Restricted')).toBeInTheDocument()
|
||||
expect(screen.getByText('Only workspace owners may modify this section.')).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'Retry' })).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,121 @@
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import ConfigString, { type IConfigStringProps } from './index'
|
||||
|
||||
const renderConfigString = (props?: Partial<IConfigStringProps>) => {
|
||||
const onChange = jest.fn()
|
||||
const defaultProps: IConfigStringProps = {
|
||||
value: 5,
|
||||
maxLength: 10,
|
||||
modelId: 'model-id',
|
||||
onChange,
|
||||
}
|
||||
|
||||
render(<ConfigString {...defaultProps} {...props} />)
|
||||
|
||||
return { onChange }
|
||||
}
|
||||
|
||||
describe('ConfigString', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('Rendering', () => {
|
||||
it('should render numeric input with bounds', () => {
|
||||
renderConfigString({ value: 3, maxLength: 8 })
|
||||
|
||||
const input = screen.getByRole('spinbutton')
|
||||
|
||||
expect(input).toHaveValue(3)
|
||||
expect(input).toHaveAttribute('min', '1')
|
||||
expect(input).toHaveAttribute('max', '8')
|
||||
})
|
||||
|
||||
it('should render empty input when value is undefined', () => {
|
||||
const { onChange } = renderConfigString({ value: undefined })
|
||||
|
||||
expect(screen.getByRole('spinbutton')).toHaveValue(null)
|
||||
expect(onChange).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Effect behavior', () => {
|
||||
it('should clamp initial value to maxLength when it exceeds limit', async () => {
|
||||
const onChange = jest.fn()
|
||||
render(
|
||||
<ConfigString
|
||||
value={15}
|
||||
maxLength={10}
|
||||
modelId="model-id"
|
||||
onChange={onChange}
|
||||
/>,
|
||||
)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onChange).toHaveBeenCalledWith(10)
|
||||
})
|
||||
expect(onChange).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should clamp when updated prop value exceeds maxLength', async () => {
|
||||
const onChange = jest.fn()
|
||||
const { rerender } = render(
|
||||
<ConfigString
|
||||
value={4}
|
||||
maxLength={6}
|
||||
modelId="model-id"
|
||||
onChange={onChange}
|
||||
/>,
|
||||
)
|
||||
|
||||
rerender(
|
||||
<ConfigString
|
||||
value={9}
|
||||
maxLength={6}
|
||||
modelId="model-id"
|
||||
onChange={onChange}
|
||||
/>,
|
||||
)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onChange).toHaveBeenCalledWith(6)
|
||||
})
|
||||
expect(onChange).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('User interactions', () => {
|
||||
it('should clamp entered value above maxLength', () => {
|
||||
const { onChange } = renderConfigString({ maxLength: 7 })
|
||||
|
||||
fireEvent.change(screen.getByRole('spinbutton'), { target: { value: '12' } })
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith(7)
|
||||
})
|
||||
|
||||
it('should raise value below minimum to one', () => {
|
||||
const { onChange } = renderConfigString()
|
||||
|
||||
fireEvent.change(screen.getByRole('spinbutton'), { target: { value: '0' } })
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith(1)
|
||||
})
|
||||
|
||||
it('should forward parsed value when within bounds', () => {
|
||||
const { onChange } = renderConfigString({ maxLength: 9 })
|
||||
|
||||
fireEvent.change(screen.getByRole('spinbutton'), { target: { value: '7' } })
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith(7)
|
||||
})
|
||||
|
||||
it('should pass through NaN when input is cleared', () => {
|
||||
const { onChange } = renderConfigString()
|
||||
|
||||
fireEvent.change(screen.getByRole('spinbutton'), { target: { value: '' } })
|
||||
|
||||
expect(onChange).toHaveBeenCalledTimes(1)
|
||||
expect(onChange.mock.calls[0][0]).toBeNaN()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,45 @@
|
||||
import React from 'react'
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import SelectTypeItem from './index'
|
||||
import { InputVarType } from '@/app/components/workflow/types'
|
||||
|
||||
describe('SelectTypeItem', () => {
|
||||
// Rendering pathways based on type and selection state
|
||||
describe('Rendering', () => {
|
||||
test('should render ok', () => {
|
||||
// Arrange
|
||||
const { container } = render(
|
||||
<SelectTypeItem
|
||||
type={InputVarType.textInput}
|
||||
selected={false}
|
||||
onClick={jest.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByText('appDebug.variableConfig.text-input')).toBeInTheDocument()
|
||||
expect(container.querySelector('svg')).not.toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
// User interaction outcomes
|
||||
describe('Interactions', () => {
|
||||
test('should trigger onClick when item is pressed', () => {
|
||||
const handleClick = jest.fn()
|
||||
// Arrange
|
||||
render(
|
||||
<SelectTypeItem
|
||||
type={InputVarType.paragraph}
|
||||
selected={false}
|
||||
onClick={handleClick}
|
||||
/>,
|
||||
)
|
||||
|
||||
// Act
|
||||
fireEvent.click(screen.getByText('appDebug.variableConfig.paragraph'))
|
||||
|
||||
// Assert
|
||||
expect(handleClick).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,142 @@
|
||||
'use client'
|
||||
import type { FC } from 'react'
|
||||
import React, { useCallback } from 'react'
|
||||
import { RiArrowDownSLine } from '@remixicon/react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Button from '@/app/components/base/button'
|
||||
import Indicator from '@/app/components/header/indicator'
|
||||
import Authorize from '@/app/components/plugins/plugin-auth/authorize'
|
||||
import Authorized from '@/app/components/plugins/plugin-auth/authorized'
|
||||
import { AuthCategory } from '@/app/components/plugins/plugin-auth'
|
||||
import { usePluginAuth } from '@/app/components/plugins/plugin-auth/hooks/use-plugin-auth'
|
||||
import type { Credential } from '@/app/components/plugins/plugin-auth/types'
|
||||
import cn from '@/utils/classnames'
|
||||
import type { CollectionType } from '@/app/components/tools/types'
|
||||
|
||||
type GroupAuthControlProps = {
|
||||
providerId: string
|
||||
providerName: string
|
||||
providerType: CollectionType
|
||||
credentialId?: string
|
||||
onChange: (credentialId: string) => void
|
||||
}
|
||||
|
||||
const GroupAuthControl: FC<GroupAuthControlProps> = ({
|
||||
providerId,
|
||||
providerName,
|
||||
providerType,
|
||||
credentialId,
|
||||
onChange,
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
const {
|
||||
isAuthorized,
|
||||
canOAuth,
|
||||
canApiKey,
|
||||
credentials,
|
||||
disabled,
|
||||
invalidPluginCredentialInfo,
|
||||
notAllowCustomCredential,
|
||||
} = usePluginAuth({
|
||||
provider: providerName,
|
||||
providerType,
|
||||
category: AuthCategory.tool,
|
||||
detail: { id: providerId, name: providerName, type: providerType } as any,
|
||||
}, true)
|
||||
|
||||
const extraAuthorizationItems: Credential[] = [
|
||||
{
|
||||
id: '__workspace_default__',
|
||||
name: t('plugin.auth.workspaceDefault'),
|
||||
provider: '',
|
||||
is_default: !credentialId,
|
||||
isWorkspaceDefault: true,
|
||||
},
|
||||
]
|
||||
|
||||
const handleAuthorizationItemClick = useCallback((id: string) => {
|
||||
onChange(id === '__workspace_default__' ? '' : id)
|
||||
}, [onChange])
|
||||
|
||||
const renderTrigger = useCallback((open?: boolean) => {
|
||||
let label = ''
|
||||
let removed = false
|
||||
let unavailable = false
|
||||
let color = 'green'
|
||||
if (!credentialId) {
|
||||
label = t('plugin.auth.workspaceDefault')
|
||||
}
|
||||
else {
|
||||
const credential = credentials.find(c => c.id === credentialId)
|
||||
label = credential ? credential.name : t('plugin.auth.authRemoved')
|
||||
removed = !credential
|
||||
unavailable = !!credential?.not_allowed_to_use && !credential?.from_enterprise
|
||||
if (removed)
|
||||
color = 'red'
|
||||
else if (unavailable)
|
||||
color = 'gray'
|
||||
}
|
||||
|
||||
return (
|
||||
<Button
|
||||
className={cn(
|
||||
'h-9',
|
||||
open && 'bg-components-button-secondary-bg-hover',
|
||||
removed && 'text-text-destructive',
|
||||
)}
|
||||
variant='secondary'
|
||||
size='small'
|
||||
>
|
||||
<Indicator className='mr-2' color={color as any} />
|
||||
{label}
|
||||
{
|
||||
unavailable && t('plugin.auth.unavailable')
|
||||
}
|
||||
<RiArrowDownSLine className='ml-0.5 h-4 w-4' />
|
||||
</Button>
|
||||
)
|
||||
}, [credentialId, credentials, t])
|
||||
|
||||
if (!isAuthorized) {
|
||||
return (
|
||||
<Authorize
|
||||
pluginPayload={{
|
||||
provider: providerName,
|
||||
providerType,
|
||||
category: AuthCategory.tool,
|
||||
detail: { id: providerId, name: providerName, type: providerType } as any,
|
||||
}}
|
||||
canOAuth={canOAuth}
|
||||
canApiKey={canApiKey}
|
||||
disabled={disabled}
|
||||
onUpdate={invalidPluginCredentialInfo}
|
||||
notAllowCustomCredential={notAllowCustomCredential}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Authorized
|
||||
pluginPayload={{
|
||||
provider: providerName,
|
||||
providerType,
|
||||
category: AuthCategory.tool,
|
||||
detail: { id: providerId, name: providerName, type: providerType } as any,
|
||||
}}
|
||||
credentials={credentials}
|
||||
canOAuth={canOAuth}
|
||||
canApiKey={canApiKey}
|
||||
disabled={disabled}
|
||||
disableSetDefault
|
||||
onItemClick={handleAuthorizationItemClick}
|
||||
extraAuthorizationItems={extraAuthorizationItems}
|
||||
showItemSelectedIcon
|
||||
renderTrigger={renderTrigger}
|
||||
selectedCredentialId={credentialId || '__workspace_default__'}
|
||||
onUpdate={invalidPluginCredentialInfo}
|
||||
notAllowCustomCredential={notAllowCustomCredential}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export default React.memo(GroupAuthControl)
|
||||
@@ -6,6 +6,7 @@ import { useContext } from 'use-context-selector'
|
||||
import copy from 'copy-to-clipboard'
|
||||
import { produce } from 'immer'
|
||||
import {
|
||||
RiArrowDownSLine,
|
||||
RiDeleteBinLine,
|
||||
RiEqualizer2Line,
|
||||
RiInformation2Line,
|
||||
@@ -24,7 +25,6 @@ import { type Collection, CollectionType } from '@/app/components/tools/types'
|
||||
import { MAX_TOOLS_NUM } from '@/config'
|
||||
import { AlertTriangle } from '@/app/components/base/icons/src/vender/solid/alertsAndFeedback'
|
||||
import Tooltip from '@/app/components/base/tooltip'
|
||||
import { DefaultToolIcon } from '@/app/components/base/icons/src/public/other'
|
||||
import cn from '@/utils/classnames'
|
||||
import ToolPicker from '@/app/components/workflow/block-selector/tool-picker'
|
||||
import type { ToolDefaultValue, ToolValue } from '@/app/components/workflow/block-selector/types'
|
||||
@@ -33,8 +33,9 @@ import { useAllBuiltInTools, useAllCustomTools, useAllMCPTools, useAllWorkflowTo
|
||||
import type { ToolWithProvider } from '@/app/components/workflow/types'
|
||||
import { useMittContextSelector } from '@/context/mitt-context'
|
||||
import { addDefaultValue, toolParametersToFormSchemas } from '@/app/components/tools/utils/to-form-schema'
|
||||
import { DefaultToolIcon } from '@/app/components/base/icons/src/public/other'
|
||||
|
||||
type AgentToolWithMoreInfo = AgentTool & { icon: any; collection?: Collection } | null
|
||||
type AgentToolWithMoreInfo = (AgentTool & { icon: any; collection?: Collection; use_end_user_credentials?: boolean; end_user_credential_type?: string }) | null
|
||||
const AgentTools: FC = () => {
|
||||
const { t } = useTranslation()
|
||||
const [isShowChooseTool, setIsShowChooseTool] = useState(false)
|
||||
@@ -93,6 +94,13 @@ const AgentTools: FC = () => {
|
||||
}
|
||||
|
||||
const [isDeleting, setIsDeleting] = useState<number>(-1)
|
||||
const [expandedProviders, setExpandedProviders] = useState<Record<string, boolean>>({})
|
||||
const toggleProviderExpand = useCallback((providerId: string) => {
|
||||
setExpandedProviders(prev => ({
|
||||
...prev,
|
||||
[providerId]: !prev[providerId],
|
||||
}))
|
||||
}, [])
|
||||
const getToolValue = (tool: ToolDefaultValue) => {
|
||||
const currToolInCollections = collectionList.find(c => c.id === tool.provider_id)
|
||||
const currToolWithConfigs = currToolInCollections?.tools.find(t => t.name === tool.tool_name)
|
||||
@@ -107,7 +115,9 @@ const AgentTools: FC = () => {
|
||||
tool_parameters: paramsWithDefaultValue,
|
||||
notAuthor: !tool.is_team_authorization,
|
||||
enabled: true,
|
||||
}
|
||||
use_end_user_credentials: false,
|
||||
end_user_credential_type: '',
|
||||
} as any
|
||||
}
|
||||
const handleSelectTool = (tool: ToolDefaultValue) => {
|
||||
const newModelConfig = produce(modelConfig, (draft) => {
|
||||
@@ -143,6 +153,34 @@ const AgentTools: FC = () => {
|
||||
formattingChangedDispatcher()
|
||||
}, [currentTool, modelConfig, setModelConfig, formattingChangedDispatcher])
|
||||
|
||||
const handleEndUserCredentialChange = useCallback((enabled: boolean) => {
|
||||
const newModelConfig = produce(modelConfig, (draft) => {
|
||||
const tool = (draft.agentConfig.tools).find((item: any) => item.provider_id === currentTool?.provider_id)
|
||||
if (tool)
|
||||
(tool as AgentTool).use_end_user_credentials = enabled
|
||||
})
|
||||
setCurrentTool({
|
||||
...currentTool,
|
||||
use_end_user_credentials: enabled,
|
||||
} as any)
|
||||
setModelConfig(newModelConfig)
|
||||
formattingChangedDispatcher()
|
||||
}, [currentTool, modelConfig, setModelConfig, formattingChangedDispatcher])
|
||||
|
||||
const handleEndUserCredentialTypeChange = useCallback((type: string) => {
|
||||
const newModelConfig = produce(modelConfig, (draft) => {
|
||||
const tool = (draft.agentConfig.tools).find((item: any) => item.provider_id === currentTool?.provider_id)
|
||||
if (tool)
|
||||
(tool as AgentTool).end_user_credential_type = type
|
||||
})
|
||||
setCurrentTool({
|
||||
...currentTool,
|
||||
end_user_credential_type: type,
|
||||
} as any)
|
||||
setModelConfig(newModelConfig)
|
||||
formattingChangedDispatcher()
|
||||
}, [currentTool, modelConfig, setModelConfig, formattingChangedDispatcher])
|
||||
|
||||
return (
|
||||
<>
|
||||
<Panel
|
||||
@@ -182,135 +220,307 @@ const AgentTools: FC = () => {
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className='grid grid-cols-1 flex-wrap items-center justify-between gap-1 2xl:grid-cols-2'>
|
||||
{tools.map((item: AgentTool & { icon: any; collection?: Collection }, index) => (
|
||||
<div key={index}
|
||||
className={cn(
|
||||
'cursor group relative flex w-full items-center justify-between rounded-lg border-[0.5px] border-components-panel-border-subtle bg-components-panel-on-panel-item-bg p-1.5 pr-2 shadow-xs last-of-type:mb-0 hover:bg-components-panel-on-panel-item-bg-hover hover:shadow-sm',
|
||||
isDeleting === index && 'border-state-destructive-border hover:bg-state-destructive-hover',
|
||||
)}
|
||||
<div className='space-y-2'>
|
||||
{Object.values(
|
||||
tools.reduce((acc, item, idx) => {
|
||||
const key = item.provider_id
|
||||
if (!acc[key]) {
|
||||
acc[key] = {
|
||||
providerId: item.provider_id,
|
||||
providerName: getProviderShowName(item) || '',
|
||||
icon: item.icon,
|
||||
providerType: item.provider_type,
|
||||
tools: [] as (AgentTool & { __index: number })[],
|
||||
}
|
||||
}
|
||||
acc[key].tools.push({ ...item, __index: idx })
|
||||
return acc
|
||||
}, {} as Record<string, { providerId: string; providerName: string; providerType: CollectionType; icon: any; tools: (AgentTool & { __index: number })[] }>),
|
||||
).map(group => (
|
||||
<div
|
||||
key={group.providerId}
|
||||
className='rounded-lg border border-components-panel-border-subtle bg-components-panel-on-panel-item-bg p-2 shadow-xs'
|
||||
>
|
||||
<div className='flex w-0 grow items-center'>
|
||||
{item.isDeleted && <DefaultToolIcon className='h-5 w-5' />}
|
||||
{!item.isDeleted && (
|
||||
<div className={cn((item.notAuthor || !item.enabled) && 'shrink-0 opacity-50')}>
|
||||
{typeof item.icon === 'string' && <div className='h-5 w-5 rounded-md bg-cover bg-center' style={{ backgroundImage: `url(${item.icon})` }} />}
|
||||
{typeof item.icon !== 'string' && <AppIcon className='rounded-md' size='xs' icon={item.icon?.content} background={item.icon?.background} />}
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
<div
|
||||
className='flex cursor-pointer items-center gap-2 px-1'
|
||||
onClick={() => toggleProviderExpand(group.providerId)}
|
||||
>
|
||||
<RiArrowDownSLine
|
||||
className={cn(
|
||||
'system-xs-regular ml-1.5 flex w-0 grow items-center truncate',
|
||||
(item.isDeleted || item.notAuthor || !item.enabled) ? 'opacity-50' : '',
|
||||
'h-4 w-4 shrink-0 text-text-tertiary transition-transform',
|
||||
!expandedProviders[group.providerId] && '-rotate-90',
|
||||
)}
|
||||
>
|
||||
<span className='system-xs-medium pr-1.5 text-text-secondary'>{getProviderShowName(item)}</span>
|
||||
<span className='text-text-tertiary'>{item.tool_label}</span>
|
||||
{!item.isDeleted && (
|
||||
<Tooltip
|
||||
popupContent={
|
||||
<div className='w-[180px]'>
|
||||
<div className='mb-1.5 text-text-secondary'>{item.tool_name}</div>
|
||||
<div className='mb-1.5 text-text-tertiary'>{t('tools.toolNameUsageTip')}</div>
|
||||
<div className='cursor-pointer text-text-accent' onClick={() => copy(item.tool_name)}>{t('tools.copyToolName')}</div>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className='h-4 w-4'>
|
||||
<div className='ml-0.5 hidden group-hover:inline-block'>
|
||||
<RiInformation2Line className='h-4 w-4 text-text-tertiary' />
|
||||
</div>
|
||||
</div>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className='ml-1 flex shrink-0 items-center'>
|
||||
{item.isDeleted && (
|
||||
<div className='mr-2 flex items-center'>
|
||||
<Tooltip
|
||||
popupContent={t('tools.toolRemoved')}
|
||||
>
|
||||
<div className='mr-1 cursor-pointer rounded-md p-1 hover:bg-black/5'>
|
||||
<AlertTriangle className='h-4 w-4 text-[#F79009]' />
|
||||
</div>
|
||||
</Tooltip>
|
||||
<div
|
||||
className='cursor-pointer rounded-md p-1 text-text-tertiary hover:text-text-destructive'
|
||||
onClick={() => {
|
||||
const newModelConfig = produce(modelConfig, (draft) => {
|
||||
draft.agentConfig.tools.splice(index, 1)
|
||||
})
|
||||
setModelConfig(newModelConfig)
|
||||
formattingChangedDispatcher()
|
||||
}}
|
||||
onMouseOver={() => setIsDeleting(index)}
|
||||
onMouseLeave={() => setIsDeleting(-1)}
|
||||
>
|
||||
<RiDeleteBinLine className='h-4 w-4' />
|
||||
</div>
|
||||
/>
|
||||
{typeof group.icon === 'string'
|
||||
? <div className='h-5 w-5 shrink-0 rounded-md bg-cover bg-center' style={{ backgroundImage: `url(${group.icon})` }} />
|
||||
: <AppIcon className='shrink-0 rounded-md' size='xs' icon={group.icon?.content} background={group.icon?.background} />}
|
||||
<div className='system-sm-semibold truncate text-text-secondary'>{group.providerName}</div>
|
||||
<div className='ml-auto flex shrink-0 items-center gap-2'>
|
||||
<div className='system-xs-regular text-text-tertiary'>
|
||||
{group.tools.filter(tool => tool.enabled).length}/{group.tools.length} {t('appDebug.agent.tools.enabled')}
|
||||
</div>
|
||||
)}
|
||||
{!item.isDeleted && (
|
||||
<div className='mr-2 hidden items-center gap-1 group-hover:flex'>
|
||||
{!item.notAuthor && (
|
||||
<Tooltip
|
||||
popupContent={t('tools.setBuiltInTools.infoAndSetting')}
|
||||
needsDelay={false}
|
||||
>
|
||||
<div className='cursor-pointer rounded-md p-1 hover:bg-black/5' onClick={() => {
|
||||
setCurrentTool(item)
|
||||
setIsShowSettingTool(true)
|
||||
}}>
|
||||
<RiEqualizer2Line className='h-4 w-4 text-text-tertiary' />
|
||||
</div>
|
||||
</Tooltip>
|
||||
)}
|
||||
<div
|
||||
className='cursor-pointer rounded-md p-1 text-text-tertiary hover:text-text-destructive'
|
||||
onClick={() => {
|
||||
const newModelConfig = produce(modelConfig, (draft) => {
|
||||
draft.agentConfig.tools.splice(index, 1)
|
||||
})
|
||||
setModelConfig(newModelConfig)
|
||||
formattingChangedDispatcher()
|
||||
{group.tools.every(tool => tool.notAuthor) && (
|
||||
<Button
|
||||
variant='secondary'
|
||||
size='small'
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
const first = group.tools[0]
|
||||
setCurrentTool(first as any)
|
||||
setIsShowSettingTool(true)
|
||||
}}
|
||||
onMouseOver={() => setIsDeleting(index)}
|
||||
onMouseLeave={() => setIsDeleting(-1)}
|
||||
>
|
||||
<RiDeleteBinLine className='h-4 w-4' />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className={cn(item.isDeleted && 'opacity-50')}>
|
||||
{!item.notAuthor && (
|
||||
<Switch
|
||||
defaultValue={item.isDeleted ? false : item.enabled}
|
||||
disabled={item.isDeleted}
|
||||
size='md'
|
||||
onChange={(enabled) => {
|
||||
const newModelConfig = produce(modelConfig, (draft) => {
|
||||
(draft.agentConfig.tools[index] as any).enabled = enabled
|
||||
})
|
||||
setModelConfig(newModelConfig)
|
||||
formattingChangedDispatcher()
|
||||
}} />
|
||||
)}
|
||||
{item.notAuthor && (
|
||||
<Button variant='secondary' size='small' onClick={() => {
|
||||
setCurrentTool(item)
|
||||
setIsShowSettingTool(true)
|
||||
}}>
|
||||
{t('tools.notAuthorized')}
|
||||
<Indicator className='ml-2' color='orange' />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className={cn('space-y-1', expandedProviders[group.providerId] ? 'mt-1' : 'hidden')}>
|
||||
{group.tools.map(item => (
|
||||
<div
|
||||
key={`${item.provider_id}-${item.tool_name}`}
|
||||
className={cn(
|
||||
'group relative flex w-full items-center justify-between rounded-lg pl-[21px] pr-2 hover:bg-state-base-hover',
|
||||
isDeleting === item.__index && 'border border-state-destructive-border hover:bg-state-destructive-hover',
|
||||
)}
|
||||
>
|
||||
<div className='flex w-0 grow items-center'>
|
||||
<div
|
||||
className={cn(
|
||||
'system-xs-regular flex w-0 grow items-center truncate border-l-2 border-divider-subtle pl-4',
|
||||
(item.isDeleted || item.notAuthor || !item.enabled) ? 'opacity-50' : '',
|
||||
)}
|
||||
>
|
||||
<span className='system-xs-medium pr-1.5 text-text-secondary'>{item.tool_label}</span>
|
||||
<span className='text-text-tertiary'>{item.tool_name}</span>
|
||||
{!item.isDeleted && (
|
||||
<Tooltip
|
||||
popupContent={
|
||||
<div className='w-[180px]'>
|
||||
<div className='mb-1.5 text-text-secondary'>{item.tool_name}</div>
|
||||
<div className='mb-1.5 text-text-tertiary'>{t('tools.toolNameUsageTip')}</div>
|
||||
<div className='cursor-pointer text-text-accent' onClick={() => copy(item.tool_name)}>{t('tools.copyToolName')}</div>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className='h-4 w-4'>
|
||||
<div className='ml-0.5 hidden group-hover:inline-block'>
|
||||
<RiInformation2Line className='h-4 w-4 text-text-tertiary' />
|
||||
</div>
|
||||
</div>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='flex shrink-0 items-center space-x-2'>
|
||||
{item.isDeleted && (
|
||||
<div className='mr-2 flex items-center'>
|
||||
<Tooltip
|
||||
popupContent={t('tools.toolRemoved')}
|
||||
>
|
||||
<div className='mr-1 cursor-pointer rounded-md p-1 hover:bg-black/5'>
|
||||
<AlertTriangle className='h-4 w-4 text-[#F79009]' />
|
||||
</div>
|
||||
</Tooltip>
|
||||
<div
|
||||
className='cursor-pointer rounded-md p-1 text-text-tertiary hover:text-text-destructive'
|
||||
onClick={() => {
|
||||
const newModelConfig = produce(modelConfig, (draft) => {
|
||||
draft.agentConfig.tools.splice(item.__index, 1)
|
||||
})
|
||||
setModelConfig(newModelConfig)
|
||||
formattingChangedDispatcher()
|
||||
}}
|
||||
onMouseOver={() => setIsDeleting(item.__index)}
|
||||
onMouseLeave={() => setIsDeleting(-1)}
|
||||
>
|
||||
<RiDeleteBinLine className='h-4 w-4' />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{!item.isDeleted && (
|
||||
<div className='pointer-events-none mr-2 flex items-center gap-1 opacity-0 transition-opacity duration-150 group-hover:pointer-events-auto group-hover:opacity-100'>
|
||||
{!item.notAuthor && (
|
||||
<Tooltip
|
||||
popupContent={t('tools.setBuiltInTools.infoAndSetting')}
|
||||
>
|
||||
<div className='cursor-pointer rounded-md p-1 hover:bg-black/5' onClick={() => {
|
||||
setCurrentTool(item as any)
|
||||
setIsShowSettingTool(true)
|
||||
}}>
|
||||
<RiEqualizer2Line className='h-4 w-4 text-text-tertiary' />
|
||||
</div>
|
||||
</Tooltip>
|
||||
)}
|
||||
<div
|
||||
className='cursor-pointer rounded-md p-1 text-text-tertiary hover:text-text-destructive'
|
||||
onClick={() => {
|
||||
const newModelConfig = produce(modelConfig, (draft) => {
|
||||
draft.agentConfig.tools.splice(item.__index, 1)
|
||||
})
|
||||
setModelConfig(newModelConfig)
|
||||
formattingChangedDispatcher()
|
||||
}}
|
||||
onMouseOver={() => setIsDeleting(item.__index)}
|
||||
onMouseLeave={() => setIsDeleting(-1)}
|
||||
>
|
||||
<RiDeleteBinLine className='h-4 w-4' />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className={cn(item.isDeleted && 'opacity-50')}>
|
||||
{!item.notAuthor && (
|
||||
<Switch
|
||||
defaultValue={item.isDeleted ? false : item.enabled}
|
||||
disabled={item.isDeleted}
|
||||
size='md'
|
||||
onChange={(enabled) => {
|
||||
const newModelConfig = produce(modelConfig, (draft) => {
|
||||
(draft.agentConfig.tools[item.__index] as any).enabled = enabled
|
||||
})
|
||||
setModelConfig(newModelConfig)
|
||||
formattingChangedDispatcher()
|
||||
}} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className='grid grid-cols-1 flex-wrap items-center justify-between gap-1 2xl:grid-cols-2'>
|
||||
{tools.map((item: AgentTool & { icon: any; collection?: Collection }, index) => (
|
||||
<div key={index}
|
||||
className={cn(
|
||||
'cursor group relative flex w-full items-center justify-between rounded-lg border-[0.5px] border-components-panel-border-subtle bg-components-panel-on-panel-item-bg p-1.5 pr-2 shadow-xs last-of-type:mb-0 hover:bg-components-panel-on-panel-item-bg-hover hover:shadow-sm',
|
||||
isDeleting === index && 'border-state-destructive-border hover:bg-state-destructive-hover',
|
||||
)}
|
||||
>
|
||||
<div className='flex w-0 grow items-center'>
|
||||
{item.isDeleted && <DefaultToolIcon className='h-5 w-5' />}
|
||||
{!item.isDeleted && (
|
||||
<div className={cn((item.notAuthor || !item.enabled) && 'shrink-0 opacity-50')}>
|
||||
{typeof item.icon === 'string' && <div className='h-5 w-5 rounded-md bg-cover bg-center' style={{ backgroundImage: `url(${item.icon})` }} />}
|
||||
{typeof item.icon !== 'string' && <AppIcon className='rounded-md' size='xs' icon={item.icon?.content} background={item.icon?.background} />}
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
className={cn(
|
||||
'system-xs-regular ml-1.5 flex w-0 grow items-center truncate',
|
||||
(item.isDeleted || item.notAuthor || !item.enabled) ? 'opacity-50' : '',
|
||||
)}
|
||||
>
|
||||
<span className='system-xs-medium pr-1.5 text-text-secondary'>{getProviderShowName(item)}</span>
|
||||
<span className='text-text-tertiary'>{item.tool_label}</span>
|
||||
{!item.isDeleted && (
|
||||
<Tooltip
|
||||
popupContent={
|
||||
<div className='w-[180px]'>
|
||||
<div className='mb-1.5 text-text-secondary'>{item.tool_name}</div>
|
||||
<div className='mb-1.5 text-text-tertiary'>{t('tools.toolNameUsageTip')}</div>
|
||||
<div className='cursor-pointer text-text-accent' onClick={() => copy(item.tool_name)}>{t('tools.copyToolName')}</div>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className='h-4 w-4'>
|
||||
<div className='ml-0.5 hidden group-hover:inline-block'>
|
||||
<RiInformation2Line className='h-4 w-4 text-text-tertiary' />
|
||||
</div>
|
||||
</div>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className='ml-1 flex shrink-0 items-center'>
|
||||
{item.isDeleted && (
|
||||
<div className='mr-2 flex items-center'>
|
||||
<Tooltip
|
||||
popupContent={t('tools.toolRemoved')}
|
||||
>
|
||||
<div className='mr-1 cursor-pointer rounded-md p-1 hover:bg-black/5'>
|
||||
<AlertTriangle className='h-4 w-4 text-[#F79009]' />
|
||||
</div>
|
||||
</Tooltip>
|
||||
<div
|
||||
className='cursor-pointer rounded-md p-1 text-text-tertiary hover:text-text-destructive'
|
||||
onClick={() => {
|
||||
const newModelConfig = produce(modelConfig, (draft) => {
|
||||
draft.agentConfig.tools.splice(index, 1)
|
||||
})
|
||||
setModelConfig(newModelConfig)
|
||||
formattingChangedDispatcher()
|
||||
}}
|
||||
onMouseOver={() => setIsDeleting(index)}
|
||||
onMouseLeave={() => setIsDeleting(-1)}
|
||||
>
|
||||
<RiDeleteBinLine className='h-4 w-4' />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{!item.isDeleted && (
|
||||
<div className='mr-2 hidden items-center gap-1 group-hover:flex'>
|
||||
{!item.notAuthor && (
|
||||
<Tooltip
|
||||
popupContent={t('tools.setBuiltInTools.infoAndSetting')}
|
||||
needsDelay={false}
|
||||
>
|
||||
<div className='cursor-pointer rounded-md p-1 hover:bg-black/5' onClick={() => {
|
||||
setCurrentTool(item)
|
||||
setIsShowSettingTool(true)
|
||||
}}>
|
||||
<RiEqualizer2Line className='h-4 w-4 text-text-tertiary' />
|
||||
</div>
|
||||
</Tooltip>
|
||||
)}
|
||||
<div
|
||||
className='cursor-pointer rounded-md p-1 text-text-tertiary hover:text-text-destructive'
|
||||
onClick={() => {
|
||||
const newModelConfig = produce(modelConfig, (draft) => {
|
||||
draft.agentConfig.tools.splice(index, 1)
|
||||
})
|
||||
setModelConfig(newModelConfig)
|
||||
formattingChangedDispatcher()
|
||||
}}
|
||||
onMouseOver={() => setIsDeleting(index)}
|
||||
onMouseLeave={() => setIsDeleting(-1)}
|
||||
>
|
||||
<RiDeleteBinLine className='h-4 w-4' />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className={cn(item.isDeleted && 'opacity-50')}>
|
||||
{!item.notAuthor && (
|
||||
<Switch
|
||||
defaultValue={item.isDeleted ? false : item.enabled}
|
||||
disabled={item.isDeleted}
|
||||
size='md'
|
||||
onChange={(enabled) => {
|
||||
const newModelConfig = produce(modelConfig, (draft) => {
|
||||
(draft.agentConfig.tools[index] as any).enabled = enabled
|
||||
})
|
||||
setModelConfig(newModelConfig)
|
||||
formattingChangedDispatcher()
|
||||
}} />
|
||||
)}
|
||||
{item.notAuthor && (
|
||||
<Button variant='secondary' size='small' onClick={() => {
|
||||
setCurrentTool(item)
|
||||
setIsShowSettingTool(true)
|
||||
}}>
|
||||
{t('tools.notAuthorized')}
|
||||
<Indicator className='ml-2' color='orange' />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div >
|
||||
</Panel >
|
||||
</div>
|
||||
</Panel>
|
||||
{isShowSettingTool && (
|
||||
<SettingBuiltInTool
|
||||
toolName={currentTool?.tool_name as string}
|
||||
@@ -321,6 +531,10 @@ const AgentTools: FC = () => {
|
||||
onHide={() => setIsShowSettingTool(false)}
|
||||
credentialId={currentTool?.credential_id}
|
||||
onAuthorizationItemClick={handleAuthorizationItemClick}
|
||||
useEndUserCredentialEnabled={currentTool?.use_end_user_credentials}
|
||||
endUserCredentialType={currentTool?.end_user_credential_type}
|
||||
onEndUserCredentialChange={handleEndUserCredentialChange}
|
||||
onEndUserCredentialTypeChange={handleEndUserCredentialTypeChange}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
|
||||
+12
@@ -42,6 +42,10 @@ type Props = {
|
||||
onSave?: (value: Record<string, any>) => void
|
||||
credentialId?: string
|
||||
onAuthorizationItemClick?: (id: string) => void
|
||||
useEndUserCredentialEnabled?: boolean
|
||||
endUserCredentialType?: string
|
||||
onEndUserCredentialChange?: (enabled: boolean) => void
|
||||
onEndUserCredentialTypeChange?: (type: string) => void
|
||||
}
|
||||
|
||||
const SettingBuiltInTool: FC<Props> = ({
|
||||
@@ -56,6 +60,10 @@ const SettingBuiltInTool: FC<Props> = ({
|
||||
onSave,
|
||||
credentialId,
|
||||
onAuthorizationItemClick,
|
||||
useEndUserCredentialEnabled,
|
||||
endUserCredentialType,
|
||||
onEndUserCredentialChange,
|
||||
onEndUserCredentialTypeChange,
|
||||
}) => {
|
||||
const { locale } = useContext(I18n)
|
||||
const language = getLanguage(locale)
|
||||
@@ -216,6 +224,10 @@ const SettingBuiltInTool: FC<Props> = ({
|
||||
}}
|
||||
credentialId={credentialId}
|
||||
onAuthorizationItemClick={onAuthorizationItemClick}
|
||||
useEndUserCredentialEnabled={useEndUserCredentialEnabled}
|
||||
endUserCredentialType={endUserCredentialType}
|
||||
onEndUserCredentialChange={onEndUserCredentialChange}
|
||||
onEndUserCredentialTypeChange={onEndUserCredentialTypeChange}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,242 @@
|
||||
import { fireEvent, render, screen, waitFor, within } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import Item from './index'
|
||||
import type React from 'react'
|
||||
import type { DataSet } from '@/models/datasets'
|
||||
import { ChunkingMode, DataSourceType, DatasetPermission } from '@/models/datasets'
|
||||
import type { IndexingType } from '@/app/components/datasets/create/step-two'
|
||||
import type { RetrievalConfig } from '@/types/app'
|
||||
import { RETRIEVE_METHOD } from '@/types/app'
|
||||
import useBreakpoints, { MediaType } from '@/hooks/use-breakpoints'
|
||||
|
||||
jest.mock('../settings-modal', () => ({
|
||||
__esModule: true,
|
||||
default: ({ onSave, onCancel, currentDataset }: any) => (
|
||||
<div>
|
||||
<div>Mock settings modal</div>
|
||||
<button onClick={() => onSave({ ...currentDataset, name: 'Updated dataset' })}>Save changes</button>
|
||||
<button onClick={onCancel}>Close</button>
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
jest.mock('@/hooks/use-breakpoints', () => {
|
||||
const actual = jest.requireActual('@/hooks/use-breakpoints')
|
||||
return {
|
||||
__esModule: true,
|
||||
...actual,
|
||||
default: jest.fn(() => actual.MediaType.pc),
|
||||
}
|
||||
})
|
||||
|
||||
const mockedUseBreakpoints = useBreakpoints as jest.MockedFunction<typeof useBreakpoints>
|
||||
|
||||
const baseRetrievalConfig: RetrievalConfig = {
|
||||
search_method: RETRIEVE_METHOD.semantic,
|
||||
reranking_enable: false,
|
||||
reranking_model: {
|
||||
reranking_provider_name: 'provider',
|
||||
reranking_model_name: 'rerank-model',
|
||||
},
|
||||
top_k: 4,
|
||||
score_threshold_enabled: false,
|
||||
score_threshold: 0,
|
||||
}
|
||||
|
||||
const defaultIndexingTechnique: IndexingType = 'high_quality' as IndexingType
|
||||
|
||||
const createDataset = (overrides: Partial<DataSet> = {}): DataSet => {
|
||||
const {
|
||||
retrieval_model,
|
||||
retrieval_model_dict,
|
||||
icon_info,
|
||||
...restOverrides
|
||||
} = overrides
|
||||
|
||||
const resolvedRetrievalModelDict = {
|
||||
...baseRetrievalConfig,
|
||||
...retrieval_model_dict,
|
||||
}
|
||||
const resolvedRetrievalModel = {
|
||||
...baseRetrievalConfig,
|
||||
...(retrieval_model ?? retrieval_model_dict),
|
||||
}
|
||||
|
||||
const defaultIconInfo = {
|
||||
icon: '📘',
|
||||
icon_type: 'emoji',
|
||||
icon_background: '#FFEAD5',
|
||||
icon_url: '',
|
||||
}
|
||||
|
||||
const resolvedIconInfo = ('icon_info' in overrides)
|
||||
? icon_info
|
||||
: defaultIconInfo
|
||||
|
||||
return {
|
||||
id: 'dataset-id',
|
||||
name: 'Dataset Name',
|
||||
indexing_status: 'completed',
|
||||
icon_info: resolvedIconInfo as DataSet['icon_info'],
|
||||
description: 'A test dataset',
|
||||
permission: DatasetPermission.onlyMe,
|
||||
data_source_type: DataSourceType.FILE,
|
||||
indexing_technique: defaultIndexingTechnique,
|
||||
author_name: 'author',
|
||||
created_by: 'creator',
|
||||
updated_by: 'updater',
|
||||
updated_at: 0,
|
||||
app_count: 0,
|
||||
doc_form: ChunkingMode.text,
|
||||
document_count: 0,
|
||||
total_document_count: 0,
|
||||
total_available_documents: 0,
|
||||
word_count: 0,
|
||||
provider: 'dify',
|
||||
embedding_model: 'text-embedding',
|
||||
embedding_model_provider: 'openai',
|
||||
embedding_available: true,
|
||||
retrieval_model_dict: resolvedRetrievalModelDict,
|
||||
retrieval_model: resolvedRetrievalModel,
|
||||
tags: [],
|
||||
external_knowledge_info: {
|
||||
external_knowledge_id: 'external-id',
|
||||
external_knowledge_api_id: 'api-id',
|
||||
external_knowledge_api_name: 'api-name',
|
||||
external_knowledge_api_endpoint: 'https://endpoint',
|
||||
},
|
||||
external_retrieval_model: {
|
||||
top_k: 2,
|
||||
score_threshold: 0.5,
|
||||
score_threshold_enabled: true,
|
||||
},
|
||||
built_in_field_enabled: true,
|
||||
doc_metadata: [],
|
||||
keyword_number: 3,
|
||||
pipeline_id: 'pipeline-id',
|
||||
is_published: true,
|
||||
runtime_mode: 'general',
|
||||
enable_api: true,
|
||||
is_multimodal: false,
|
||||
...restOverrides,
|
||||
}
|
||||
}
|
||||
|
||||
const renderItem = (config: DataSet, props?: Partial<React.ComponentProps<typeof Item>>) => {
|
||||
const onSave = jest.fn()
|
||||
const onRemove = jest.fn()
|
||||
|
||||
render(
|
||||
<Item
|
||||
config={config}
|
||||
onSave={onSave}
|
||||
onRemove={onRemove}
|
||||
{...props}
|
||||
/>,
|
||||
)
|
||||
|
||||
return { onSave, onRemove }
|
||||
}
|
||||
|
||||
describe('dataset-config/card-item', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks()
|
||||
mockedUseBreakpoints.mockReturnValue(MediaType.pc)
|
||||
})
|
||||
|
||||
it('should render dataset details with indexing and external badges', () => {
|
||||
const dataset = createDataset({
|
||||
provider: 'external',
|
||||
retrieval_model_dict: {
|
||||
...baseRetrievalConfig,
|
||||
search_method: RETRIEVE_METHOD.semantic,
|
||||
},
|
||||
})
|
||||
|
||||
renderItem(dataset)
|
||||
|
||||
const card = screen.getByText(dataset.name).closest('.group') as HTMLElement
|
||||
const actionButtons = within(card).getAllByRole('button', { hidden: true })
|
||||
|
||||
expect(screen.getByText(dataset.name)).toBeInTheDocument()
|
||||
expect(screen.getByText('dataset.indexingTechnique.high_quality · dataset.indexingMethod.semantic_search')).toBeInTheDocument()
|
||||
expect(screen.getByText('dataset.externalTag')).toBeInTheDocument()
|
||||
expect(actionButtons).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('should open settings drawer from edit action and close after saving', async () => {
|
||||
const user = userEvent.setup()
|
||||
const dataset = createDataset()
|
||||
const { onSave } = renderItem(dataset)
|
||||
|
||||
const card = screen.getByText(dataset.name).closest('.group') as HTMLElement
|
||||
const [editButton] = within(card).getAllByRole('button', { hidden: true })
|
||||
await user.click(editButton)
|
||||
|
||||
expect(screen.getByText('Mock settings modal')).toBeInTheDocument()
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('dialog')).toBeVisible()
|
||||
})
|
||||
|
||||
await user.click(screen.getByText('Save changes'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onSave).toHaveBeenCalledWith(expect.objectContaining({ name: 'Updated dataset' }))
|
||||
})
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Mock settings modal')).not.toBeVisible()
|
||||
})
|
||||
})
|
||||
|
||||
it('should call onRemove and toggle destructive state on hover', async () => {
|
||||
const user = userEvent.setup()
|
||||
const dataset = createDataset()
|
||||
const { onRemove } = renderItem(dataset)
|
||||
|
||||
const card = screen.getByText(dataset.name).closest('.group') as HTMLElement
|
||||
const buttons = within(card).getAllByRole('button', { hidden: true })
|
||||
const deleteButton = buttons[buttons.length - 1]
|
||||
|
||||
expect(deleteButton.className).not.toContain('action-btn-destructive')
|
||||
|
||||
fireEvent.mouseEnter(deleteButton)
|
||||
expect(deleteButton.className).toContain('action-btn-destructive')
|
||||
expect(card.className).toContain('border-state-destructive-border')
|
||||
|
||||
fireEvent.mouseLeave(deleteButton)
|
||||
expect(deleteButton.className).not.toContain('action-btn-destructive')
|
||||
|
||||
await user.click(deleteButton)
|
||||
expect(onRemove).toHaveBeenCalledWith(dataset.id)
|
||||
})
|
||||
|
||||
it('should use default icon information when icon details are missing', () => {
|
||||
const dataset = createDataset({ icon_info: undefined })
|
||||
|
||||
renderItem(dataset)
|
||||
|
||||
const nameElement = screen.getByText(dataset.name)
|
||||
const iconElement = nameElement.parentElement?.firstElementChild as HTMLElement
|
||||
|
||||
expect(iconElement).toHaveStyle({ background: '#FFF4ED' })
|
||||
expect(iconElement.querySelector('em-emoji')).toHaveAttribute('id', '📙')
|
||||
})
|
||||
|
||||
it('should apply mask overlay on mobile when drawer is open', async () => {
|
||||
mockedUseBreakpoints.mockReturnValue(MediaType.mobile)
|
||||
const user = userEvent.setup()
|
||||
const dataset = createDataset()
|
||||
|
||||
renderItem(dataset)
|
||||
|
||||
const card = screen.getByText(dataset.name).closest('.group') as HTMLElement
|
||||
const [editButton] = within(card).getAllByRole('button', { hidden: true })
|
||||
await user.click(editButton)
|
||||
expect(screen.getByText('Mock settings modal')).toBeInTheDocument()
|
||||
|
||||
const overlay = Array.from(document.querySelectorAll('[class]'))
|
||||
.find(element => element.className.toString().includes('bg-black/30'))
|
||||
|
||||
expect(overlay).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,299 @@
|
||||
import * as React from 'react'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import ContextVar from './index'
|
||||
import type { Props } from './var-picker'
|
||||
|
||||
// Mock external dependencies only
|
||||
jest.mock('next/navigation', () => ({
|
||||
useRouter: () => ({ push: jest.fn() }),
|
||||
usePathname: () => '/test',
|
||||
}))
|
||||
|
||||
type PortalToFollowElemProps = {
|
||||
children: React.ReactNode
|
||||
open?: boolean
|
||||
onOpenChange?: (open: boolean) => void
|
||||
}
|
||||
type PortalToFollowElemTriggerProps = React.HTMLAttributes<HTMLElement> & { children?: React.ReactNode; asChild?: boolean }
|
||||
type PortalToFollowElemContentProps = React.HTMLAttributes<HTMLDivElement> & { children?: React.ReactNode }
|
||||
|
||||
jest.mock('@/app/components/base/portal-to-follow-elem', () => {
|
||||
const PortalContext = React.createContext({ open: false })
|
||||
|
||||
const PortalToFollowElem = ({ children, open }: PortalToFollowElemProps) => {
|
||||
return (
|
||||
<PortalContext.Provider value={{ open: !!open }}>
|
||||
<div data-testid="portal">{children}</div>
|
||||
</PortalContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
const PortalToFollowElemContent = ({ children, ...props }: PortalToFollowElemContentProps) => {
|
||||
const { open } = React.useContext(PortalContext)
|
||||
if (!open) return null
|
||||
return (
|
||||
<div data-testid="portal-content" {...props}>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const PortalToFollowElemTrigger = ({ children, asChild, ...props }: PortalToFollowElemTriggerProps) => {
|
||||
if (asChild && React.isValidElement(children)) {
|
||||
return React.cloneElement(children, {
|
||||
...props,
|
||||
'data-testid': 'portal-trigger',
|
||||
} as React.HTMLAttributes<HTMLElement>)
|
||||
}
|
||||
return (
|
||||
<div data-testid="portal-trigger" {...props}>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return {
|
||||
PortalToFollowElem,
|
||||
PortalToFollowElemContent,
|
||||
PortalToFollowElemTrigger,
|
||||
}
|
||||
})
|
||||
|
||||
describe('ContextVar', () => {
|
||||
const mockOptions: Props['options'] = [
|
||||
{ name: 'Variable 1', value: 'var1', type: 'string' },
|
||||
{ name: 'Variable 2', value: 'var2', type: 'number' },
|
||||
]
|
||||
|
||||
const defaultProps: Props = {
|
||||
value: 'var1',
|
||||
options: mockOptions,
|
||||
onChange: jest.fn(),
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks()
|
||||
})
|
||||
|
||||
// Rendering tests (REQUIRED)
|
||||
describe('Rendering', () => {
|
||||
it('should display query variable selector when options are provided', () => {
|
||||
// Arrange
|
||||
const props = { ...defaultProps }
|
||||
|
||||
// Act
|
||||
render(<ContextVar {...props} />)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByText('appDebug.feature.dataSet.queryVariable.title')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show selected variable with proper formatting when value is provided', () => {
|
||||
// Arrange
|
||||
const props = { ...defaultProps }
|
||||
|
||||
// Act
|
||||
render(<ContextVar {...props} />)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByText('var1')).toBeInTheDocument()
|
||||
expect(screen.getByText('{{')).toBeInTheDocument()
|
||||
expect(screen.getByText('}}')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// Props tests (REQUIRED)
|
||||
describe('Props', () => {
|
||||
it('should display selected variable when value prop is provided', () => {
|
||||
// Arrange
|
||||
const props = { ...defaultProps, value: 'var2' }
|
||||
|
||||
// Act
|
||||
render(<ContextVar {...props} />)
|
||||
|
||||
// Assert - Should display the selected value
|
||||
expect(screen.getByText('var2')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show placeholder text when no value is selected', () => {
|
||||
// Arrange
|
||||
const props = {
|
||||
...defaultProps,
|
||||
value: undefined,
|
||||
}
|
||||
|
||||
// Act
|
||||
render(<ContextVar {...props} />)
|
||||
|
||||
// Assert - Should show placeholder instead of variable
|
||||
expect(screen.queryByText('var1')).not.toBeInTheDocument()
|
||||
expect(screen.getByText('appDebug.feature.dataSet.queryVariable.choosePlaceholder')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should display custom tip message when notSelectedVarTip is provided', () => {
|
||||
// Arrange
|
||||
const props = {
|
||||
...defaultProps,
|
||||
value: undefined,
|
||||
notSelectedVarTip: 'Select a variable',
|
||||
}
|
||||
|
||||
// Act
|
||||
render(<ContextVar {...props} />)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByText('Select a variable')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should apply custom className to VarPicker when provided', () => {
|
||||
// Arrange
|
||||
const props = {
|
||||
...defaultProps,
|
||||
className: 'custom-class',
|
||||
}
|
||||
|
||||
// Act
|
||||
const { container } = render(<ContextVar {...props} />)
|
||||
|
||||
// Assert
|
||||
expect(container.querySelector('.custom-class')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// User Interactions
|
||||
describe('User Interactions', () => {
|
||||
it('should call onChange when user selects a different variable', async () => {
|
||||
// Arrange
|
||||
const onChange = jest.fn()
|
||||
const props = { ...defaultProps, onChange }
|
||||
const user = userEvent.setup()
|
||||
|
||||
// Act
|
||||
render(<ContextVar {...props} />)
|
||||
|
||||
const triggers = screen.getAllByTestId('portal-trigger')
|
||||
const varPickerTrigger = triggers[triggers.length - 1]
|
||||
|
||||
await user.click(varPickerTrigger)
|
||||
expect(screen.getByTestId('portal-content')).toBeInTheDocument()
|
||||
|
||||
// Select a different option
|
||||
const options = screen.getAllByText('var2')
|
||||
expect(options.length).toBeGreaterThan(0)
|
||||
await user.click(options[0])
|
||||
|
||||
// Assert
|
||||
expect(onChange).toHaveBeenCalledWith('var2')
|
||||
expect(screen.queryByTestId('portal-content')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should toggle dropdown when clicking the trigger button', async () => {
|
||||
// Arrange
|
||||
const props = { ...defaultProps }
|
||||
const user = userEvent.setup()
|
||||
|
||||
// Act
|
||||
render(<ContextVar {...props} />)
|
||||
|
||||
const triggers = screen.getAllByTestId('portal-trigger')
|
||||
const varPickerTrigger = triggers[triggers.length - 1]
|
||||
|
||||
// Open dropdown
|
||||
await user.click(varPickerTrigger)
|
||||
expect(screen.getByTestId('portal-content')).toBeInTheDocument()
|
||||
|
||||
// Close dropdown
|
||||
await user.click(varPickerTrigger)
|
||||
expect(screen.queryByTestId('portal-content')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// Edge Cases (REQUIRED)
|
||||
describe('Edge Cases', () => {
|
||||
it('should handle undefined value gracefully', () => {
|
||||
// Arrange
|
||||
const props = {
|
||||
...defaultProps,
|
||||
value: undefined,
|
||||
}
|
||||
|
||||
// Act
|
||||
render(<ContextVar {...props} />)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByText('appDebug.feature.dataSet.queryVariable.title')).toBeInTheDocument()
|
||||
expect(screen.getByText('appDebug.feature.dataSet.queryVariable.choosePlaceholder')).toBeInTheDocument()
|
||||
expect(screen.queryByText('var1')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle empty options array', () => {
|
||||
// Arrange
|
||||
const props = {
|
||||
...defaultProps,
|
||||
options: [],
|
||||
value: undefined,
|
||||
}
|
||||
|
||||
// Act
|
||||
render(<ContextVar {...props} />)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByText('appDebug.feature.dataSet.queryVariable.title')).toBeInTheDocument()
|
||||
expect(screen.getByText('appDebug.feature.dataSet.queryVariable.choosePlaceholder')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle null value without crashing', () => {
|
||||
// Arrange
|
||||
const props = {
|
||||
...defaultProps,
|
||||
value: undefined,
|
||||
}
|
||||
|
||||
// Act
|
||||
render(<ContextVar {...props} />)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByText('appDebug.feature.dataSet.queryVariable.title')).toBeInTheDocument()
|
||||
expect(screen.getByText('appDebug.feature.dataSet.queryVariable.choosePlaceholder')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle options with different data types', () => {
|
||||
// Arrange
|
||||
const props = {
|
||||
...defaultProps,
|
||||
options: [
|
||||
{ name: 'String Var', value: 'strVar', type: 'string' },
|
||||
{ name: 'Number Var', value: '42', type: 'number' },
|
||||
{ name: 'Boolean Var', value: 'true', type: 'boolean' },
|
||||
],
|
||||
value: 'strVar',
|
||||
}
|
||||
|
||||
// Act
|
||||
render(<ContextVar {...props} />)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByText('strVar')).toBeInTheDocument()
|
||||
expect(screen.getByText('{{')).toBeInTheDocument()
|
||||
expect(screen.getByText('}}')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render variable names with special characters safely', () => {
|
||||
// Arrange
|
||||
const props = {
|
||||
...defaultProps,
|
||||
options: [
|
||||
{ name: 'Variable with & < > " \' characters', value: 'specialVar', type: 'string' },
|
||||
],
|
||||
value: 'specialVar',
|
||||
}
|
||||
|
||||
// Act
|
||||
render(<ContextVar {...props} />)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByText('specialVar')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,392 @@
|
||||
import * as React from 'react'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import VarPicker, { type Props } from './var-picker'
|
||||
|
||||
// Mock external dependencies only
|
||||
jest.mock('next/navigation', () => ({
|
||||
useRouter: () => ({ push: jest.fn() }),
|
||||
usePathname: () => '/test',
|
||||
}))
|
||||
|
||||
type PortalToFollowElemProps = {
|
||||
children: React.ReactNode
|
||||
open?: boolean
|
||||
onOpenChange?: (open: boolean) => void
|
||||
}
|
||||
type PortalToFollowElemTriggerProps = React.HTMLAttributes<HTMLElement> & { children?: React.ReactNode; asChild?: boolean }
|
||||
type PortalToFollowElemContentProps = React.HTMLAttributes<HTMLDivElement> & { children?: React.ReactNode }
|
||||
|
||||
jest.mock('@/app/components/base/portal-to-follow-elem', () => {
|
||||
const PortalContext = React.createContext({ open: false })
|
||||
|
||||
const PortalToFollowElem = ({ children, open }: PortalToFollowElemProps) => {
|
||||
return (
|
||||
<PortalContext.Provider value={{ open: !!open }}>
|
||||
<div data-testid="portal">{children}</div>
|
||||
</PortalContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
const PortalToFollowElemContent = ({ children, ...props }: PortalToFollowElemContentProps) => {
|
||||
const { open } = React.useContext(PortalContext)
|
||||
if (!open) return null
|
||||
return (
|
||||
<div data-testid="portal-content" {...props}>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const PortalToFollowElemTrigger = ({ children, asChild, ...props }: PortalToFollowElemTriggerProps) => {
|
||||
if (asChild && React.isValidElement(children)) {
|
||||
return React.cloneElement(children, {
|
||||
...props,
|
||||
'data-testid': 'portal-trigger',
|
||||
} as React.HTMLAttributes<HTMLElement>)
|
||||
}
|
||||
return (
|
||||
<div data-testid="portal-trigger" {...props}>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return {
|
||||
PortalToFollowElem,
|
||||
PortalToFollowElemContent,
|
||||
PortalToFollowElemTrigger,
|
||||
}
|
||||
})
|
||||
|
||||
describe('VarPicker', () => {
|
||||
const mockOptions: Props['options'] = [
|
||||
{ name: 'Variable 1', value: 'var1', type: 'string' },
|
||||
{ name: 'Variable 2', value: 'var2', type: 'number' },
|
||||
{ name: 'Variable 3', value: 'var3', type: 'boolean' },
|
||||
]
|
||||
|
||||
const defaultProps: Props = {
|
||||
value: 'var1',
|
||||
options: mockOptions,
|
||||
onChange: jest.fn(),
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks()
|
||||
})
|
||||
|
||||
// Rendering tests (REQUIRED)
|
||||
describe('Rendering', () => {
|
||||
it('should render variable picker with dropdown trigger', () => {
|
||||
// Arrange
|
||||
const props = { ...defaultProps }
|
||||
|
||||
// Act
|
||||
render(<VarPicker {...props} />)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByTestId('portal-trigger')).toBeInTheDocument()
|
||||
expect(screen.getByText('var1')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should display selected variable with type icon when value is provided', () => {
|
||||
// Arrange
|
||||
const props = { ...defaultProps }
|
||||
|
||||
// Act
|
||||
render(<VarPicker {...props} />)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByText('var1')).toBeInTheDocument()
|
||||
expect(screen.getByText('{{')).toBeInTheDocument()
|
||||
expect(screen.getByText('}}')).toBeInTheDocument()
|
||||
// IconTypeIcon should be rendered (check for svg icon)
|
||||
expect(document.querySelector('svg')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show placeholder text when no value is selected', () => {
|
||||
// Arrange
|
||||
const props = {
|
||||
...defaultProps,
|
||||
value: undefined,
|
||||
}
|
||||
|
||||
// Act
|
||||
render(<VarPicker {...props} />)
|
||||
|
||||
// Assert
|
||||
expect(screen.queryByText('var1')).not.toBeInTheDocument()
|
||||
expect(screen.getByText('appDebug.feature.dataSet.queryVariable.choosePlaceholder')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should display custom tip message when notSelectedVarTip is provided', () => {
|
||||
// Arrange
|
||||
const props = {
|
||||
...defaultProps,
|
||||
value: undefined,
|
||||
notSelectedVarTip: 'Select a variable',
|
||||
}
|
||||
|
||||
// Act
|
||||
render(<VarPicker {...props} />)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByText('Select a variable')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render dropdown indicator icon', () => {
|
||||
// Arrange
|
||||
const props = { ...defaultProps }
|
||||
|
||||
// Act
|
||||
render(<VarPicker {...props} />)
|
||||
|
||||
// Assert - Trigger should be present
|
||||
expect(screen.getByTestId('portal-trigger')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// Props tests (REQUIRED)
|
||||
describe('Props', () => {
|
||||
it('should apply custom className to wrapper', () => {
|
||||
// Arrange
|
||||
const props = {
|
||||
...defaultProps,
|
||||
className: 'custom-class',
|
||||
}
|
||||
|
||||
// Act
|
||||
const { container } = render(<VarPicker {...props} />)
|
||||
|
||||
// Assert
|
||||
expect(container.querySelector('.custom-class')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should apply custom triggerClassName to trigger button', () => {
|
||||
// Arrange
|
||||
const props = {
|
||||
...defaultProps,
|
||||
triggerClassName: 'custom-trigger-class',
|
||||
}
|
||||
|
||||
// Act
|
||||
render(<VarPicker {...props} />)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByTestId('portal-trigger')).toHaveClass('custom-trigger-class')
|
||||
})
|
||||
|
||||
it('should display selected value with proper formatting', () => {
|
||||
// Arrange
|
||||
const props = {
|
||||
...defaultProps,
|
||||
value: 'customVar',
|
||||
options: [
|
||||
{ name: 'Custom Variable', value: 'customVar', type: 'string' },
|
||||
],
|
||||
}
|
||||
|
||||
// Act
|
||||
render(<VarPicker {...props} />)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByText('customVar')).toBeInTheDocument()
|
||||
expect(screen.getByText('{{')).toBeInTheDocument()
|
||||
expect(screen.getByText('}}')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// User Interactions
|
||||
describe('User Interactions', () => {
|
||||
it('should open dropdown when clicking the trigger button', async () => {
|
||||
// Arrange
|
||||
const onChange = jest.fn()
|
||||
const props = { ...defaultProps, onChange }
|
||||
const user = userEvent.setup()
|
||||
|
||||
// Act
|
||||
render(<VarPicker {...props} />)
|
||||
await user.click(screen.getByTestId('portal-trigger'))
|
||||
|
||||
// Assert
|
||||
expect(screen.getByTestId('portal-content')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should call onChange and close dropdown when selecting an option', async () => {
|
||||
// Arrange
|
||||
const onChange = jest.fn()
|
||||
const props = { ...defaultProps, onChange }
|
||||
const user = userEvent.setup()
|
||||
|
||||
// Act
|
||||
render(<VarPicker {...props} />)
|
||||
|
||||
// Open dropdown
|
||||
await user.click(screen.getByTestId('portal-trigger'))
|
||||
expect(screen.getByTestId('portal-content')).toBeInTheDocument()
|
||||
|
||||
// Select a different option
|
||||
const options = screen.getAllByText('var2')
|
||||
expect(options.length).toBeGreaterThan(0)
|
||||
await user.click(options[0])
|
||||
|
||||
// Assert
|
||||
expect(onChange).toHaveBeenCalledWith('var2')
|
||||
expect(screen.queryByTestId('portal-content')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should toggle dropdown when clicking trigger button multiple times', async () => {
|
||||
// Arrange
|
||||
const props = { ...defaultProps }
|
||||
const user = userEvent.setup()
|
||||
|
||||
// Act
|
||||
render(<VarPicker {...props} />)
|
||||
|
||||
const trigger = screen.getByTestId('portal-trigger')
|
||||
|
||||
// Open dropdown
|
||||
await user.click(trigger)
|
||||
expect(screen.getByTestId('portal-content')).toBeInTheDocument()
|
||||
|
||||
// Close dropdown
|
||||
await user.click(trigger)
|
||||
expect(screen.queryByTestId('portal-content')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// State Management
|
||||
describe('State Management', () => {
|
||||
it('should initialize with closed dropdown', () => {
|
||||
// Arrange
|
||||
const props = { ...defaultProps }
|
||||
|
||||
// Act
|
||||
render(<VarPicker {...props} />)
|
||||
|
||||
// Assert
|
||||
expect(screen.queryByTestId('portal-content')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should toggle dropdown state on trigger click', async () => {
|
||||
// Arrange
|
||||
const props = { ...defaultProps }
|
||||
const user = userEvent.setup()
|
||||
|
||||
// Act
|
||||
render(<VarPicker {...props} />)
|
||||
|
||||
const trigger = screen.getByTestId('portal-trigger')
|
||||
expect(screen.queryByTestId('portal-content')).not.toBeInTheDocument()
|
||||
|
||||
// Open dropdown
|
||||
await user.click(trigger)
|
||||
expect(screen.getByTestId('portal-content')).toBeInTheDocument()
|
||||
|
||||
// Close dropdown
|
||||
await user.click(trigger)
|
||||
expect(screen.queryByTestId('portal-content')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should preserve selected value when dropdown is closed without selection', async () => {
|
||||
// Arrange
|
||||
const props = { ...defaultProps }
|
||||
const user = userEvent.setup()
|
||||
|
||||
// Act
|
||||
render(<VarPicker {...props} />)
|
||||
|
||||
// Open and close dropdown without selecting anything
|
||||
const trigger = screen.getByTestId('portal-trigger')
|
||||
await user.click(trigger)
|
||||
await user.click(trigger)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByText('var1')).toBeInTheDocument() // Original value still displayed
|
||||
})
|
||||
})
|
||||
|
||||
// Edge Cases (REQUIRED)
|
||||
describe('Edge Cases', () => {
|
||||
it('should handle undefined value gracefully', () => {
|
||||
// Arrange
|
||||
const props = {
|
||||
...defaultProps,
|
||||
value: undefined,
|
||||
}
|
||||
|
||||
// Act
|
||||
render(<VarPicker {...props} />)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByText('appDebug.feature.dataSet.queryVariable.choosePlaceholder')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('portal-trigger')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle empty options array', () => {
|
||||
// Arrange
|
||||
const props = {
|
||||
...defaultProps,
|
||||
options: [],
|
||||
value: undefined,
|
||||
}
|
||||
|
||||
// Act
|
||||
render(<VarPicker {...props} />)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByTestId('portal-trigger')).toBeInTheDocument()
|
||||
expect(screen.getByText('appDebug.feature.dataSet.queryVariable.choosePlaceholder')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle null value without crashing', () => {
|
||||
// Arrange
|
||||
const props = {
|
||||
...defaultProps,
|
||||
value: undefined,
|
||||
}
|
||||
|
||||
// Act
|
||||
render(<VarPicker {...props} />)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByText('appDebug.feature.dataSet.queryVariable.choosePlaceholder')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle variable names with special characters safely', () => {
|
||||
// Arrange
|
||||
const props = {
|
||||
...defaultProps,
|
||||
options: [
|
||||
{ name: 'Variable with & < > " \' characters', value: 'specialVar', type: 'string' },
|
||||
],
|
||||
value: 'specialVar',
|
||||
}
|
||||
|
||||
// Act
|
||||
render(<VarPicker {...props} />)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByText('specialVar')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle long variable names', () => {
|
||||
// Arrange
|
||||
const props = {
|
||||
...defaultProps,
|
||||
options: [
|
||||
{ name: 'A very long variable name that should be truncated', value: 'longVar', type: 'string' },
|
||||
],
|
||||
value: 'longVar',
|
||||
}
|
||||
|
||||
// Act
|
||||
render(<VarPicker {...props} />)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByText('longVar')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('portal-trigger')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,347 @@
|
||||
import { render, screen, within } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import AppCard from './index'
|
||||
import type { AppIconType } from '@/types/app'
|
||||
import { AppModeEnum } from '@/types/app'
|
||||
import type { App } from '@/models/explore'
|
||||
|
||||
jest.mock('@heroicons/react/20/solid', () => ({
|
||||
PlusIcon: ({ className }: any) => <div data-testid="plus-icon" className={className} aria-label="Add icon">+</div>,
|
||||
}))
|
||||
|
||||
const mockApp: App = {
|
||||
app: {
|
||||
id: 'test-app-id',
|
||||
mode: AppModeEnum.CHAT,
|
||||
icon_type: 'emoji' as AppIconType,
|
||||
icon: '🤖',
|
||||
icon_background: '#FFEAD5',
|
||||
icon_url: '',
|
||||
name: 'Test Chat App',
|
||||
description: 'A test chat application for demonstration purposes',
|
||||
use_icon_as_answer_icon: false,
|
||||
},
|
||||
app_id: 'test-app-id',
|
||||
description: 'A comprehensive chat application template',
|
||||
copyright: 'Test Corp',
|
||||
privacy_policy: null,
|
||||
custom_disclaimer: null,
|
||||
category: 'Assistant',
|
||||
position: 1,
|
||||
is_listed: true,
|
||||
install_count: 100,
|
||||
installed: false,
|
||||
editable: true,
|
||||
is_agent: false,
|
||||
}
|
||||
|
||||
describe('AppCard', () => {
|
||||
const defaultProps = {
|
||||
app: mockApp,
|
||||
canCreate: true,
|
||||
onCreate: jest.fn(),
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('Rendering', () => {
|
||||
it('should render without crashing', () => {
|
||||
const { container } = render(<AppCard {...defaultProps} />)
|
||||
|
||||
expect(container.querySelector('em-emoji')).toBeInTheDocument()
|
||||
expect(screen.getByText('Test Chat App')).toBeInTheDocument()
|
||||
expect(screen.getByText(mockApp.description)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render app type icon and label', () => {
|
||||
const { container } = render(<AppCard {...defaultProps} />)
|
||||
|
||||
expect(container.querySelector('svg')).toBeInTheDocument()
|
||||
expect(screen.getByText('app.typeSelector.chatbot')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Props', () => {
|
||||
describe('canCreate behavior', () => {
|
||||
it('should show create button when canCreate is true', () => {
|
||||
render(<AppCard {...defaultProps} canCreate={true} />)
|
||||
|
||||
const button = screen.getByRole('button', { name: /app\.newApp\.useTemplate/ })
|
||||
expect(button).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should hide create button when canCreate is false', () => {
|
||||
render(<AppCard {...defaultProps} canCreate={false} />)
|
||||
|
||||
const button = screen.queryByRole('button', { name: /app\.newApp\.useTemplate/ })
|
||||
expect(button).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('should display app name from appBasicInfo', () => {
|
||||
const customApp = {
|
||||
...mockApp,
|
||||
app: {
|
||||
...mockApp.app,
|
||||
name: 'Custom App Name',
|
||||
},
|
||||
}
|
||||
render(<AppCard {...defaultProps} app={customApp} />)
|
||||
|
||||
expect(screen.getByText('Custom App Name')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should display app description from app level', () => {
|
||||
const customApp = {
|
||||
...mockApp,
|
||||
description: 'Custom description for the app',
|
||||
}
|
||||
render(<AppCard {...defaultProps} app={customApp} />)
|
||||
|
||||
expect(screen.getByText('Custom description for the app')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should truncate long app names', () => {
|
||||
const longNameApp = {
|
||||
...mockApp,
|
||||
app: {
|
||||
...mockApp.app,
|
||||
name: 'This is a very long app name that should be truncated with line-clamp-1',
|
||||
},
|
||||
}
|
||||
render(<AppCard {...defaultProps} app={longNameApp} />)
|
||||
|
||||
const nameElement = screen.getByTitle('This is a very long app name that should be truncated with line-clamp-1')
|
||||
expect(nameElement).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('App Modes - Data Driven Tests', () => {
|
||||
const testCases = [
|
||||
{
|
||||
mode: AppModeEnum.CHAT,
|
||||
expectedLabel: 'app.typeSelector.chatbot',
|
||||
description: 'Chat application mode',
|
||||
},
|
||||
{
|
||||
mode: AppModeEnum.AGENT_CHAT,
|
||||
expectedLabel: 'app.typeSelector.agent',
|
||||
description: 'Agent chat mode',
|
||||
},
|
||||
{
|
||||
mode: AppModeEnum.COMPLETION,
|
||||
expectedLabel: 'app.typeSelector.completion',
|
||||
description: 'Completion mode',
|
||||
},
|
||||
{
|
||||
mode: AppModeEnum.ADVANCED_CHAT,
|
||||
expectedLabel: 'app.typeSelector.advanced',
|
||||
description: 'Advanced chat mode',
|
||||
},
|
||||
{
|
||||
mode: AppModeEnum.WORKFLOW,
|
||||
expectedLabel: 'app.typeSelector.workflow',
|
||||
description: 'Workflow mode',
|
||||
},
|
||||
]
|
||||
|
||||
testCases.forEach(({ mode, expectedLabel, description }) => {
|
||||
it(`should display correct type label for ${description}`, () => {
|
||||
const appWithMode = {
|
||||
...mockApp,
|
||||
app: {
|
||||
...mockApp.app,
|
||||
mode,
|
||||
},
|
||||
}
|
||||
render(<AppCard {...defaultProps} app={appWithMode} />)
|
||||
|
||||
expect(screen.getByText(expectedLabel)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('Icon Type Tests', () => {
|
||||
it('should render emoji icon without image element', () => {
|
||||
const appWithIcon = {
|
||||
...mockApp,
|
||||
app: {
|
||||
...mockApp.app,
|
||||
icon_type: 'emoji' as AppIconType,
|
||||
icon: '🤖',
|
||||
},
|
||||
}
|
||||
const { container } = render(<AppCard {...defaultProps} app={appWithIcon} />)
|
||||
|
||||
const card = container.firstElementChild as HTMLElement
|
||||
expect(within(card).queryByRole('img', { name: 'app icon' })).not.toBeInTheDocument()
|
||||
expect(card.querySelector('em-emoji')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should prioritize icon_url when both icon and icon_url are provided', () => {
|
||||
const appWithImageUrl = {
|
||||
...mockApp,
|
||||
app: {
|
||||
...mockApp.app,
|
||||
icon_type: 'image' as AppIconType,
|
||||
icon: 'local-icon.png',
|
||||
icon_url: 'https://example.com/remote-icon.png',
|
||||
},
|
||||
}
|
||||
render(<AppCard {...defaultProps} app={appWithImageUrl} />)
|
||||
|
||||
expect(screen.getByRole('img', { name: 'app icon' })).toHaveAttribute('src', 'https://example.com/remote-icon.png')
|
||||
})
|
||||
})
|
||||
|
||||
describe('User Interactions', () => {
|
||||
it('should call onCreate when create button is clicked', async () => {
|
||||
const mockOnCreate = jest.fn()
|
||||
render(<AppCard {...defaultProps} onCreate={mockOnCreate} />)
|
||||
|
||||
const button = screen.getByRole('button', { name: /app\.newApp\.useTemplate/ })
|
||||
await userEvent.click(button)
|
||||
expect(mockOnCreate).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should handle click on card itself', async () => {
|
||||
const mockOnCreate = jest.fn()
|
||||
const { container } = render(<AppCard {...defaultProps} onCreate={mockOnCreate} />)
|
||||
|
||||
const card = container.firstElementChild as HTMLElement
|
||||
await userEvent.click(card)
|
||||
// Note: Card click doesn't trigger onCreate, only the button does
|
||||
expect(mockOnCreate).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Keyboard Accessibility', () => {
|
||||
it('should allow the create button to be focused', async () => {
|
||||
const mockOnCreate = jest.fn()
|
||||
render(<AppCard {...defaultProps} onCreate={mockOnCreate} />)
|
||||
|
||||
await userEvent.tab()
|
||||
const button = screen.getByRole('button', { name: /app\.newApp\.useTemplate/ }) as HTMLButtonElement
|
||||
|
||||
// Test that button can be focused
|
||||
expect(button).toHaveFocus()
|
||||
|
||||
// Test click event works (keyboard events on buttons typically trigger click)
|
||||
await userEvent.click(button)
|
||||
expect(mockOnCreate).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
it('should handle app with null icon_type', () => {
|
||||
const appWithNullIcon = {
|
||||
...mockApp,
|
||||
app: {
|
||||
...mockApp.app,
|
||||
icon_type: null,
|
||||
},
|
||||
}
|
||||
const { container } = render(<AppCard {...defaultProps} app={appWithNullIcon} />)
|
||||
|
||||
const appIcon = container.querySelector('em-emoji')
|
||||
expect(appIcon).toBeInTheDocument()
|
||||
// AppIcon component should handle null icon_type gracefully
|
||||
})
|
||||
|
||||
it('should handle app with empty description', () => {
|
||||
const appWithEmptyDesc = {
|
||||
...mockApp,
|
||||
description: '',
|
||||
}
|
||||
const { container } = render(<AppCard {...defaultProps} app={appWithEmptyDesc} />)
|
||||
|
||||
const descriptionContainer = container.querySelector('.line-clamp-3')
|
||||
expect(descriptionContainer).toBeInTheDocument()
|
||||
expect(descriptionContainer).toHaveTextContent('')
|
||||
})
|
||||
|
||||
it('should handle app with very long description', () => {
|
||||
const longDescription = 'This is a very long description that should be truncated with line-clamp-3. '.repeat(5)
|
||||
const appWithLongDesc = {
|
||||
...mockApp,
|
||||
description: longDescription,
|
||||
}
|
||||
render(<AppCard {...defaultProps} app={appWithLongDesc} />)
|
||||
|
||||
expect(screen.getByText(/This is a very long description/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle app with special characters in name', () => {
|
||||
const appWithSpecialChars = {
|
||||
...mockApp,
|
||||
app: {
|
||||
...mockApp.app,
|
||||
name: 'App <script>alert("test")</script> & Special "Chars"',
|
||||
},
|
||||
}
|
||||
render(<AppCard {...defaultProps} app={appWithSpecialChars} />)
|
||||
|
||||
expect(screen.getByText('App <script>alert("test")</script> & Special "Chars"')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle onCreate function throwing error', async () => {
|
||||
const errorOnCreate = jest.fn(() => {
|
||||
throw new Error('Create failed')
|
||||
})
|
||||
|
||||
// Mock console.error to avoid test output noise
|
||||
const consoleSpy = jest.spyOn(console, 'error').mockImplementation(jest.fn())
|
||||
|
||||
render(<AppCard {...defaultProps} onCreate={errorOnCreate} />)
|
||||
|
||||
const button = screen.getByRole('button', { name: /app\.newApp\.useTemplate/ })
|
||||
let capturedError: unknown
|
||||
try {
|
||||
await userEvent.click(button)
|
||||
}
|
||||
catch (err) {
|
||||
capturedError = err
|
||||
}
|
||||
expect(errorOnCreate).toHaveBeenCalledTimes(1)
|
||||
expect(consoleSpy).toHaveBeenCalled()
|
||||
if (capturedError instanceof Error)
|
||||
expect(capturedError.message).toContain('Create failed')
|
||||
|
||||
consoleSpy.mockRestore()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Accessibility', () => {
|
||||
it('should have proper elements for accessibility', () => {
|
||||
const { container } = render(<AppCard {...defaultProps} />)
|
||||
|
||||
expect(container.querySelector('em-emoji')).toBeInTheDocument()
|
||||
expect(container.querySelector('svg')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should have title attribute for app name when truncated', () => {
|
||||
render(<AppCard {...defaultProps} />)
|
||||
|
||||
const nameElement = screen.getByText('Test Chat App')
|
||||
expect(nameElement).toHaveAttribute('title', 'Test Chat App')
|
||||
})
|
||||
|
||||
it('should have accessible button with proper label', () => {
|
||||
render(<AppCard {...defaultProps} />)
|
||||
|
||||
const button = screen.getByRole('button', { name: /app\.newApp\.useTemplate/ })
|
||||
expect(button).toBeEnabled()
|
||||
expect(button).toHaveTextContent('app.newApp.useTemplate')
|
||||
})
|
||||
})
|
||||
|
||||
describe('User-Visible Behavior Tests', () => {
|
||||
it('should show plus icon in create button', () => {
|
||||
render(<AppCard {...defaultProps} />)
|
||||
|
||||
expect(screen.getByTestId('plus-icon')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -15,6 +15,7 @@ export type AppCardProps = {
|
||||
|
||||
const AppCard = ({
|
||||
app,
|
||||
canCreate,
|
||||
onCreate,
|
||||
}: AppCardProps) => {
|
||||
const { t } = useTranslation()
|
||||
@@ -45,14 +46,16 @@ const AppCard = ({
|
||||
{app.description}
|
||||
</div>
|
||||
</div>
|
||||
<div className={cn('absolute bottom-0 left-0 right-0 hidden bg-gradient-to-t from-components-panel-gradient-2 from-[60.27%] to-transparent p-4 pt-8 group-hover:flex')}>
|
||||
<div className={cn('flex h-8 w-full items-center space-x-2')}>
|
||||
<Button variant='primary' className='grow' onClick={() => onCreate()}>
|
||||
<PlusIcon className='mr-1 h-4 w-4' />
|
||||
<span className='text-xs'>{t('app.newApp.useTemplate')}</span>
|
||||
</Button>
|
||||
{canCreate && (
|
||||
<div className={cn('absolute bottom-0 left-0 right-0 hidden bg-gradient-to-t from-components-panel-gradient-2 from-[60.27%] to-transparent p-4 pt-8 group-hover:flex')}>
|
||||
<div className={cn('flex h-8 w-full items-center space-x-2')}>
|
||||
<Button variant='primary' className='grow' onClick={() => onCreate()}>
|
||||
<PlusIcon className='mr-1 h-4 w-4' />
|
||||
<span className='text-xs'>{t('app.newApp.useTemplate')}</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,209 @@
|
||||
import type { RenderOptions } from '@testing-library/react'
|
||||
import { fireEvent, render } from '@testing-library/react'
|
||||
import { defaultPlan } from '@/app/components/billing/config'
|
||||
import { noop } from 'lodash-es'
|
||||
import type { ModalContextState } from '@/context/modal-context'
|
||||
import APIKeyInfoPanel from './index'
|
||||
|
||||
// Mock the modules before importing the functions
|
||||
jest.mock('@/context/provider-context', () => ({
|
||||
useProviderContext: jest.fn(),
|
||||
}))
|
||||
|
||||
jest.mock('@/context/modal-context', () => ({
|
||||
useModalContext: jest.fn(),
|
||||
}))
|
||||
|
||||
import { useProviderContext as actualUseProviderContext } from '@/context/provider-context'
|
||||
import { useModalContext as actualUseModalContext } from '@/context/modal-context'
|
||||
|
||||
// Type casting for mocks
|
||||
const mockUseProviderContext = actualUseProviderContext as jest.MockedFunction<typeof actualUseProviderContext>
|
||||
const mockUseModalContext = actualUseModalContext as jest.MockedFunction<typeof actualUseModalContext>
|
||||
|
||||
// Default mock data
|
||||
const defaultProviderContext = {
|
||||
modelProviders: [],
|
||||
refreshModelProviders: noop,
|
||||
textGenerationModelList: [],
|
||||
supportRetrievalMethods: [],
|
||||
isAPIKeySet: false,
|
||||
plan: defaultPlan,
|
||||
isFetchedPlan: false,
|
||||
enableBilling: false,
|
||||
onPlanInfoChanged: noop,
|
||||
enableReplaceWebAppLogo: false,
|
||||
modelLoadBalancingEnabled: false,
|
||||
datasetOperatorEnabled: false,
|
||||
enableEducationPlan: false,
|
||||
isEducationWorkspace: false,
|
||||
isEducationAccount: false,
|
||||
allowRefreshEducationVerify: false,
|
||||
educationAccountExpireAt: null,
|
||||
isLoadingEducationAccountInfo: false,
|
||||
isFetchingEducationAccountInfo: false,
|
||||
webappCopyrightEnabled: false,
|
||||
licenseLimit: {
|
||||
workspace_members: {
|
||||
size: 0,
|
||||
limit: 0,
|
||||
},
|
||||
},
|
||||
refreshLicenseLimit: noop,
|
||||
isAllowTransferWorkspace: false,
|
||||
isAllowPublishAsCustomKnowledgePipelineTemplate: false,
|
||||
}
|
||||
|
||||
const defaultModalContext: ModalContextState = {
|
||||
setShowAccountSettingModal: noop,
|
||||
setShowApiBasedExtensionModal: noop,
|
||||
setShowModerationSettingModal: noop,
|
||||
setShowExternalDataToolModal: noop,
|
||||
setShowPricingModal: noop,
|
||||
setShowAnnotationFullModal: noop,
|
||||
setShowModelModal: noop,
|
||||
setShowExternalKnowledgeAPIModal: noop,
|
||||
setShowModelLoadBalancingModal: noop,
|
||||
setShowOpeningModal: noop,
|
||||
setShowUpdatePluginModal: noop,
|
||||
setShowEducationExpireNoticeModal: noop,
|
||||
setShowTriggerEventsLimitModal: noop,
|
||||
}
|
||||
|
||||
export type MockOverrides = {
|
||||
providerContext?: Partial<typeof defaultProviderContext>
|
||||
modalContext?: Partial<typeof defaultModalContext>
|
||||
}
|
||||
|
||||
export type APIKeyInfoPanelRenderOptions = {
|
||||
mockOverrides?: MockOverrides
|
||||
} & Omit<RenderOptions, 'wrapper'>
|
||||
|
||||
// Setup function to configure mocks
|
||||
export function setupMocks(overrides: MockOverrides = {}) {
|
||||
mockUseProviderContext.mockReturnValue({
|
||||
...defaultProviderContext,
|
||||
...overrides.providerContext,
|
||||
})
|
||||
|
||||
mockUseModalContext.mockReturnValue({
|
||||
...defaultModalContext,
|
||||
...overrides.modalContext,
|
||||
})
|
||||
}
|
||||
|
||||
// Custom render function
|
||||
export function renderAPIKeyInfoPanel(options: APIKeyInfoPanelRenderOptions = {}) {
|
||||
const { mockOverrides, ...renderOptions } = options
|
||||
|
||||
setupMocks(mockOverrides)
|
||||
|
||||
return render(<APIKeyInfoPanel />, renderOptions)
|
||||
}
|
||||
|
||||
// Helper functions for common test scenarios
|
||||
export const scenarios = {
|
||||
// Render with API key not set (default)
|
||||
withAPIKeyNotSet: (overrides: MockOverrides = {}) =>
|
||||
renderAPIKeyInfoPanel({
|
||||
mockOverrides: {
|
||||
providerContext: { isAPIKeySet: false },
|
||||
...overrides,
|
||||
},
|
||||
}),
|
||||
|
||||
// Render with API key already set
|
||||
withAPIKeySet: (overrides: MockOverrides = {}) =>
|
||||
renderAPIKeyInfoPanel({
|
||||
mockOverrides: {
|
||||
providerContext: { isAPIKeySet: true },
|
||||
...overrides,
|
||||
},
|
||||
}),
|
||||
|
||||
// Render with mock modal function
|
||||
withMockModal: (mockSetShowAccountSettingModal: jest.Mock, overrides: MockOverrides = {}) =>
|
||||
renderAPIKeyInfoPanel({
|
||||
mockOverrides: {
|
||||
modalContext: { setShowAccountSettingModal: mockSetShowAccountSettingModal },
|
||||
...overrides,
|
||||
},
|
||||
}),
|
||||
}
|
||||
|
||||
// Common test assertions
|
||||
export const assertions = {
|
||||
// Should render main button
|
||||
shouldRenderMainButton: () => {
|
||||
const button = document.querySelector('button.btn-primary')
|
||||
expect(button).toBeInTheDocument()
|
||||
return button
|
||||
},
|
||||
|
||||
// Should not render at all
|
||||
shouldNotRender: (container: HTMLElement) => {
|
||||
expect(container.firstChild).toBeNull()
|
||||
},
|
||||
|
||||
// Should have correct panel styling
|
||||
shouldHavePanelStyling: (panel: HTMLElement) => {
|
||||
expect(panel).toHaveClass(
|
||||
'border-components-panel-border',
|
||||
'bg-components-panel-bg',
|
||||
'relative',
|
||||
'mb-6',
|
||||
'rounded-2xl',
|
||||
'border',
|
||||
'p-8',
|
||||
'shadow-md',
|
||||
)
|
||||
},
|
||||
|
||||
// Should have close button
|
||||
shouldHaveCloseButton: (container: HTMLElement) => {
|
||||
const closeButton = container.querySelector('.absolute.right-4.top-4')
|
||||
expect(closeButton).toBeInTheDocument()
|
||||
expect(closeButton).toHaveClass('cursor-pointer')
|
||||
return closeButton
|
||||
},
|
||||
}
|
||||
|
||||
// Common user interactions
|
||||
export const interactions = {
|
||||
// Click the main button
|
||||
clickMainButton: () => {
|
||||
const button = document.querySelector('button.btn-primary')
|
||||
if (button) fireEvent.click(button)
|
||||
return button
|
||||
},
|
||||
|
||||
// Click the close button
|
||||
clickCloseButton: (container: HTMLElement) => {
|
||||
const closeButton = container.querySelector('.absolute.right-4.top-4')
|
||||
if (closeButton) fireEvent.click(closeButton)
|
||||
return closeButton
|
||||
},
|
||||
}
|
||||
|
||||
// Text content keys for assertions
|
||||
export const textKeys = {
|
||||
selfHost: {
|
||||
titleRow1: /appOverview\.apiKeyInfo\.selfHost\.title\.row1/,
|
||||
titleRow2: /appOverview\.apiKeyInfo\.selfHost\.title\.row2/,
|
||||
setAPIBtn: /appOverview\.apiKeyInfo\.setAPIBtn/,
|
||||
tryCloud: /appOverview\.apiKeyInfo\.tryCloud/,
|
||||
},
|
||||
cloud: {
|
||||
trialTitle: /appOverview\.apiKeyInfo\.cloud\.trial\.title/,
|
||||
trialDescription: /appOverview\.apiKeyInfo\.cloud\.trial\.description/,
|
||||
setAPIBtn: /appOverview\.apiKeyInfo\.setAPIBtn/,
|
||||
},
|
||||
}
|
||||
|
||||
// Setup and cleanup utilities
|
||||
export function clearAllMocks() {
|
||||
jest.clearAllMocks()
|
||||
}
|
||||
|
||||
// Export mock functions for external access
|
||||
export { mockUseProviderContext, mockUseModalContext, defaultModalContext }
|
||||
@@ -0,0 +1,122 @@
|
||||
import { cleanup, screen } from '@testing-library/react'
|
||||
import { ACCOUNT_SETTING_TAB } from '@/app/components/header/account-setting/constants'
|
||||
import {
|
||||
assertions,
|
||||
clearAllMocks,
|
||||
defaultModalContext,
|
||||
interactions,
|
||||
mockUseModalContext,
|
||||
scenarios,
|
||||
textKeys,
|
||||
} from './apikey-info-panel.test-utils'
|
||||
|
||||
// Mock config for Cloud edition
|
||||
jest.mock('@/config', () => ({
|
||||
IS_CE_EDITION: false, // Test Cloud edition
|
||||
}))
|
||||
|
||||
afterEach(cleanup)
|
||||
|
||||
describe('APIKeyInfoPanel - Cloud Edition', () => {
|
||||
const mockSetShowAccountSettingModal = jest.fn()
|
||||
|
||||
beforeEach(() => {
|
||||
clearAllMocks()
|
||||
mockUseModalContext.mockReturnValue({
|
||||
...defaultModalContext,
|
||||
setShowAccountSettingModal: mockSetShowAccountSettingModal,
|
||||
})
|
||||
})
|
||||
|
||||
describe('Rendering', () => {
|
||||
it('should render without crashing when API key is not set', () => {
|
||||
scenarios.withAPIKeyNotSet()
|
||||
assertions.shouldRenderMainButton()
|
||||
})
|
||||
|
||||
it('should not render when API key is already set', () => {
|
||||
const { container } = scenarios.withAPIKeySet()
|
||||
assertions.shouldNotRender(container)
|
||||
})
|
||||
|
||||
it('should not render when panel is hidden by user', () => {
|
||||
const { container } = scenarios.withAPIKeyNotSet()
|
||||
interactions.clickCloseButton(container)
|
||||
assertions.shouldNotRender(container)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Cloud Edition Content', () => {
|
||||
it('should display cloud version title', () => {
|
||||
scenarios.withAPIKeyNotSet()
|
||||
expect(screen.getByText(textKeys.cloud.trialTitle)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should display emoji for cloud version', () => {
|
||||
const { container } = scenarios.withAPIKeyNotSet()
|
||||
expect(container.querySelector('em-emoji')).toBeInTheDocument()
|
||||
expect(container.querySelector('em-emoji')).toHaveAttribute('id', '😀')
|
||||
})
|
||||
|
||||
it('should display cloud version description', () => {
|
||||
scenarios.withAPIKeyNotSet()
|
||||
expect(screen.getByText(textKeys.cloud.trialDescription)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not render external link for cloud version', () => {
|
||||
const { container } = scenarios.withAPIKeyNotSet()
|
||||
expect(container.querySelector('a[href="https://cloud.dify.ai/apps"]')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should display set API button text', () => {
|
||||
scenarios.withAPIKeyNotSet()
|
||||
expect(screen.getByText(textKeys.cloud.setAPIBtn)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('User Interactions', () => {
|
||||
it('should call setShowAccountSettingModal when set API button is clicked', () => {
|
||||
scenarios.withMockModal(mockSetShowAccountSettingModal)
|
||||
|
||||
interactions.clickMainButton()
|
||||
|
||||
expect(mockSetShowAccountSettingModal).toHaveBeenCalledWith({
|
||||
payload: ACCOUNT_SETTING_TAB.PROVIDER,
|
||||
})
|
||||
})
|
||||
|
||||
it('should hide panel when close button is clicked', () => {
|
||||
const { container } = scenarios.withAPIKeyNotSet()
|
||||
expect(container.firstChild).toBeInTheDocument()
|
||||
|
||||
interactions.clickCloseButton(container)
|
||||
assertions.shouldNotRender(container)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Props and Styling', () => {
|
||||
it('should render button with primary variant', () => {
|
||||
scenarios.withAPIKeyNotSet()
|
||||
const button = screen.getByRole('button')
|
||||
expect(button).toHaveClass('btn-primary')
|
||||
})
|
||||
|
||||
it('should render panel container with correct classes', () => {
|
||||
const { container } = scenarios.withAPIKeyNotSet()
|
||||
const panel = container.firstChild as HTMLElement
|
||||
assertions.shouldHavePanelStyling(panel)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Accessibility', () => {
|
||||
it('should have button with proper role', () => {
|
||||
scenarios.withAPIKeyNotSet()
|
||||
expect(screen.getByRole('button')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should have clickable close button', () => {
|
||||
const { container } = scenarios.withAPIKeyNotSet()
|
||||
assertions.shouldHaveCloseButton(container)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,162 @@
|
||||
import { cleanup, screen } from '@testing-library/react'
|
||||
import { ACCOUNT_SETTING_TAB } from '@/app/components/header/account-setting/constants'
|
||||
import {
|
||||
assertions,
|
||||
clearAllMocks,
|
||||
defaultModalContext,
|
||||
interactions,
|
||||
mockUseModalContext,
|
||||
scenarios,
|
||||
textKeys,
|
||||
} from './apikey-info-panel.test-utils'
|
||||
|
||||
// Mock config for CE edition
|
||||
jest.mock('@/config', () => ({
|
||||
IS_CE_EDITION: true, // Test CE edition by default
|
||||
}))
|
||||
|
||||
afterEach(cleanup)
|
||||
|
||||
describe('APIKeyInfoPanel - Community Edition', () => {
|
||||
const mockSetShowAccountSettingModal = jest.fn()
|
||||
|
||||
beforeEach(() => {
|
||||
clearAllMocks()
|
||||
mockUseModalContext.mockReturnValue({
|
||||
...defaultModalContext,
|
||||
setShowAccountSettingModal: mockSetShowAccountSettingModal,
|
||||
})
|
||||
})
|
||||
|
||||
describe('Rendering', () => {
|
||||
it('should render without crashing when API key is not set', () => {
|
||||
scenarios.withAPIKeyNotSet()
|
||||
assertions.shouldRenderMainButton()
|
||||
})
|
||||
|
||||
it('should not render when API key is already set', () => {
|
||||
const { container } = scenarios.withAPIKeySet()
|
||||
assertions.shouldNotRender(container)
|
||||
})
|
||||
|
||||
it('should not render when panel is hidden by user', () => {
|
||||
const { container } = scenarios.withAPIKeyNotSet()
|
||||
interactions.clickCloseButton(container)
|
||||
assertions.shouldNotRender(container)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Content Display', () => {
|
||||
it('should display self-host title content', () => {
|
||||
scenarios.withAPIKeyNotSet()
|
||||
|
||||
expect(screen.getByText(textKeys.selfHost.titleRow1)).toBeInTheDocument()
|
||||
expect(screen.getByText(textKeys.selfHost.titleRow2)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should display set API button text', () => {
|
||||
scenarios.withAPIKeyNotSet()
|
||||
expect(screen.getByText(textKeys.selfHost.setAPIBtn)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render external link with correct href for self-host version', () => {
|
||||
const { container } = scenarios.withAPIKeyNotSet()
|
||||
const link = container.querySelector('a[href="https://cloud.dify.ai/apps"]')
|
||||
|
||||
expect(link).toBeInTheDocument()
|
||||
expect(link).toHaveAttribute('target', '_blank')
|
||||
expect(link).toHaveAttribute('rel', 'noopener noreferrer')
|
||||
expect(link).toHaveTextContent(textKeys.selfHost.tryCloud)
|
||||
})
|
||||
|
||||
it('should have external link with proper styling for self-host version', () => {
|
||||
const { container } = scenarios.withAPIKeyNotSet()
|
||||
const link = container.querySelector('a[href="https://cloud.dify.ai/apps"]')
|
||||
|
||||
expect(link).toHaveClass(
|
||||
'mt-2',
|
||||
'flex',
|
||||
'h-[26px]',
|
||||
'items-center',
|
||||
'space-x-1',
|
||||
'p-1',
|
||||
'text-xs',
|
||||
'font-medium',
|
||||
'text-[#155EEF]',
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('User Interactions', () => {
|
||||
it('should call setShowAccountSettingModal when set API button is clicked', () => {
|
||||
scenarios.withMockModal(mockSetShowAccountSettingModal)
|
||||
|
||||
interactions.clickMainButton()
|
||||
|
||||
expect(mockSetShowAccountSettingModal).toHaveBeenCalledWith({
|
||||
payload: ACCOUNT_SETTING_TAB.PROVIDER,
|
||||
})
|
||||
})
|
||||
|
||||
it('should hide panel when close button is clicked', () => {
|
||||
const { container } = scenarios.withAPIKeyNotSet()
|
||||
expect(container.firstChild).toBeInTheDocument()
|
||||
|
||||
interactions.clickCloseButton(container)
|
||||
assertions.shouldNotRender(container)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Props and Styling', () => {
|
||||
it('should render button with primary variant', () => {
|
||||
scenarios.withAPIKeyNotSet()
|
||||
const button = screen.getByRole('button')
|
||||
expect(button).toHaveClass('btn-primary')
|
||||
})
|
||||
|
||||
it('should render panel container with correct classes', () => {
|
||||
const { container } = scenarios.withAPIKeyNotSet()
|
||||
const panel = container.firstChild as HTMLElement
|
||||
assertions.shouldHavePanelStyling(panel)
|
||||
})
|
||||
})
|
||||
|
||||
describe('State Management', () => {
|
||||
it('should start with visible panel (isShow: true)', () => {
|
||||
scenarios.withAPIKeyNotSet()
|
||||
assertions.shouldRenderMainButton()
|
||||
})
|
||||
|
||||
it('should toggle visibility when close button is clicked', () => {
|
||||
const { container } = scenarios.withAPIKeyNotSet()
|
||||
expect(container.firstChild).toBeInTheDocument()
|
||||
|
||||
interactions.clickCloseButton(container)
|
||||
assertions.shouldNotRender(container)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
it('should handle provider context loading state', () => {
|
||||
scenarios.withAPIKeyNotSet({
|
||||
providerContext: {
|
||||
modelProviders: [],
|
||||
textGenerationModelList: [],
|
||||
},
|
||||
})
|
||||
assertions.shouldRenderMainButton()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Accessibility', () => {
|
||||
it('should have button with proper role', () => {
|
||||
scenarios.withAPIKeyNotSet()
|
||||
expect(screen.getByRole('button')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should have clickable close button', () => {
|
||||
const { container } = scenarios.withAPIKeyNotSet()
|
||||
assertions.shouldHaveCloseButton(container)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -24,6 +24,10 @@ import cn from '@/utils/classnames'
|
||||
import type { FileEntity } from '../../file-uploader/types'
|
||||
import { formatBooleanInputs } from '@/utils/model-config'
|
||||
import Avatar from '../../avatar'
|
||||
import ServiceConnectionPanel from '@/app/components/base/service-connection-panel'
|
||||
import type { AuthType, ServiceConnectionItem as ServiceConnectionItemType } from '@/app/components/base/service-connection-panel'
|
||||
import { Notion } from '@/app/components/base/icons/src/public/common'
|
||||
import { Google } from '@/app/components/base/icons/src/public/plugins'
|
||||
|
||||
const ChatWrapper = () => {
|
||||
const {
|
||||
@@ -167,6 +171,53 @@ const ChatWrapper = () => {
|
||||
|
||||
const [collapsed, setCollapsed] = useState(!!currentConversationId)
|
||||
|
||||
// Demo: Service connection state
|
||||
const [serviceConnections, setServiceConnections] = useState<ServiceConnectionItemType[]>([
|
||||
{
|
||||
id: 'notion',
|
||||
name: 'Notion Page Search',
|
||||
icon: <Notion className="h-6 w-6" />,
|
||||
authType: 'oauth',
|
||||
status: 'pending',
|
||||
},
|
||||
{
|
||||
id: 'gmail',
|
||||
name: 'Gmail Tools',
|
||||
icon: <img src="https://www.gstatic.com/images/branding/product/1x/gmail_2020q4_32dp.png" alt="Gmail" className="h-6 w-6" />,
|
||||
authType: 'oauth',
|
||||
status: 'pending',
|
||||
},
|
||||
{
|
||||
id: 'youtube',
|
||||
name: 'YouTube Data Upload',
|
||||
icon: <img src="https://www.youtube.com/s/desktop/f506bd45/img/favicon_32x32.png" alt="YouTube" className="h-6 w-6" />,
|
||||
authType: 'oauth',
|
||||
status: 'pending',
|
||||
},
|
||||
{
|
||||
id: 'google-serp',
|
||||
name: 'Google SerpApi Search',
|
||||
icon: <Google className="h-6 w-6" />,
|
||||
authType: 'api_key',
|
||||
status: 'pending',
|
||||
},
|
||||
])
|
||||
|
||||
const [showServiceConnection, setShowServiceConnection] = useState(true)
|
||||
|
||||
const handleServiceConnect = useCallback((serviceId: string, _authType: AuthType) => {
|
||||
// Demo: 模拟连接成功
|
||||
setServiceConnections(prev => prev.map(service =>
|
||||
service.id === serviceId
|
||||
? { ...service, status: 'connected' as const }
|
||||
: service,
|
||||
))
|
||||
}, [])
|
||||
|
||||
const handleServiceContinue = useCallback(() => {
|
||||
setShowServiceConnection(false)
|
||||
}, [])
|
||||
|
||||
const chatNode = useMemo(() => {
|
||||
if (allInputsHidden || !inputsForms.length)
|
||||
return null
|
||||
@@ -253,6 +304,23 @@ const ChatWrapper = () => {
|
||||
/>
|
||||
: null
|
||||
|
||||
// 如果需要显示服务连接面板,则显示面板而非聊天界面
|
||||
if (showServiceConnection) {
|
||||
return (
|
||||
<div className={cn(
|
||||
'flex h-full items-center justify-center overflow-auto bg-chatbot-bg',
|
||||
isMobile && 'px-4 py-8',
|
||||
)}>
|
||||
<ServiceConnectionPanel
|
||||
services={serviceConnections}
|
||||
onConnect={handleServiceConnect}
|
||||
onContinue={handleServiceContinue}
|
||||
className={cn(isMobile && 'max-w-full')}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className='h-full overflow-hidden bg-chatbot-bg'
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
'use client'
|
||||
|
||||
import type { FC } from 'react'
|
||||
import { memo, useMemo } from 'react'
|
||||
import { RiArrowRightLine } from '@remixicon/react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Button from '@/app/components/base/button'
|
||||
import ServiceItem from './service-item'
|
||||
import type { ServiceConnectionPanelProps } from './types'
|
||||
import cn from '@/utils/classnames'
|
||||
|
||||
const ServiceConnectionPanel: FC<ServiceConnectionPanelProps> = ({
|
||||
title,
|
||||
description,
|
||||
services,
|
||||
onConnect,
|
||||
onContinue,
|
||||
continueDisabled,
|
||||
continueText,
|
||||
className,
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
const allConnected = useMemo(() => {
|
||||
return services.every(service => service.status === 'connected')
|
||||
}, [services])
|
||||
|
||||
const displayTitle = title || t('share.serviceConnection.title')
|
||||
const displayDescription = description || t('share.serviceConnection.description', { count: services.length })
|
||||
|
||||
return (
|
||||
<div className={cn(
|
||||
'flex w-full max-w-[600px] flex-col items-center',
|
||||
className,
|
||||
)}>
|
||||
<div className="mb-6 text-center">
|
||||
<h2 className="system-xl-semibold mb-1 text-text-primary">
|
||||
{displayTitle}
|
||||
</h2>
|
||||
<p className="system-sm-regular text-text-tertiary">
|
||||
{displayDescription}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="w-full space-y-2">
|
||||
{services.map(service => (
|
||||
<ServiceItem
|
||||
key={service.id}
|
||||
service={service}
|
||||
onConnect={onConnect}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{onContinue && (
|
||||
<div className="mt-6 flex w-full justify-end">
|
||||
<Button
|
||||
variant="primary"
|
||||
disabled={continueDisabled ?? !allConnected}
|
||||
onClick={onContinue}
|
||||
>
|
||||
{continueText || t('share.serviceConnection.continue')}
|
||||
<RiArrowRightLine className="ml-1 h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default memo(ServiceConnectionPanel)
|
||||
|
||||
export { default as ServiceItem } from './service-item'
|
||||
export type {
|
||||
ServiceConnectionPanelProps,
|
||||
ServiceConnectionItem,
|
||||
AuthType,
|
||||
ServiceConnectionStatus,
|
||||
} from './types'
|
||||
@@ -0,0 +1,72 @@
|
||||
'use client'
|
||||
|
||||
import type { FC } from 'react'
|
||||
import { memo } from 'react'
|
||||
import { RiAddLine } from '@remixicon/react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Button from '@/app/components/base/button'
|
||||
import type { AuthType, ServiceConnectionItem } from './types'
|
||||
import cn from '@/utils/classnames'
|
||||
|
||||
type ServiceItemProps = {
|
||||
service: ServiceConnectionItem
|
||||
onConnect: (serviceId: string, authType: AuthType) => void
|
||||
}
|
||||
|
||||
const ServiceItem: FC<ServiceItemProps> = ({
|
||||
service,
|
||||
onConnect,
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
const handleConnect = () => {
|
||||
onConnect(service.id, service.authType)
|
||||
}
|
||||
|
||||
const getButtonText = () => {
|
||||
if (service.status === 'connected')
|
||||
return t('share.serviceConnection.connected')
|
||||
|
||||
if (service.authType === 'api_key')
|
||||
return t('share.serviceConnection.addApiKey')
|
||||
|
||||
return t('share.serviceConnection.connect')
|
||||
}
|
||||
|
||||
const isConnected = service.status === 'connected'
|
||||
|
||||
return (
|
||||
<div className={cn(
|
||||
'flex items-center justify-between gap-3 rounded-xl border border-components-panel-border-subtle bg-components-panel-bg px-4 py-3',
|
||||
'hover:border-components-panel-border hover:shadow-xs',
|
||||
'transition-all duration-200',
|
||||
)}>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-8 w-8 shrink-0 items-center justify-center">
|
||||
{service.icon}
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
<span className="system-sm-medium text-text-secondary">
|
||||
{service.name}
|
||||
</span>
|
||||
{service.description && (
|
||||
<span className="system-xs-regular text-text-tertiary">
|
||||
{service.description}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
variant={isConnected ? 'secondary' : 'secondary-accent'}
|
||||
size="small"
|
||||
onClick={handleConnect}
|
||||
disabled={isConnected}
|
||||
>
|
||||
{!isConnected && <RiAddLine className="mr-0.5 h-3.5 w-3.5" />}
|
||||
{getButtonText()}
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default memo(ServiceItem)
|
||||
@@ -0,0 +1,25 @@
|
||||
import type { ReactNode } from 'react'
|
||||
|
||||
export type AuthType = 'oauth' | 'api_key'
|
||||
|
||||
export type ServiceConnectionStatus = 'pending' | 'connected' | 'error'
|
||||
|
||||
export type ServiceConnectionItem = {
|
||||
id: string
|
||||
name: string
|
||||
icon: ReactNode
|
||||
authType: AuthType
|
||||
status: ServiceConnectionStatus
|
||||
description?: string
|
||||
}
|
||||
|
||||
export type ServiceConnectionPanelProps = {
|
||||
title?: string
|
||||
description?: string
|
||||
services: ServiceConnectionItem[]
|
||||
onConnect: (serviceId: string, authType: AuthType) => void
|
||||
onContinue?: () => void
|
||||
continueDisabled?: boolean
|
||||
continueText?: string
|
||||
className?: string
|
||||
}
|
||||
@@ -33,7 +33,10 @@ const PlanUpgradeModal: FC<Props> = ({
|
||||
|
||||
const handleUpgrade = useCallback(() => {
|
||||
onClose()
|
||||
onUpgrade ? onUpgrade() : setShowPricingModal()
|
||||
if (onUpgrade)
|
||||
onUpgrade()
|
||||
else
|
||||
setShowPricingModal()
|
||||
}, [onClose, onUpgrade, setShowPricingModal])
|
||||
|
||||
return (
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import React from 'react'
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import Button from './button'
|
||||
import { Plan } from '../../../type'
|
||||
|
||||
describe('CloudPlanButton', () => {
|
||||
describe('Disabled state', () => {
|
||||
test('should disable button and hide arrow when plan is not available', () => {
|
||||
const handleGetPayUrl = jest.fn()
|
||||
// Arrange
|
||||
render(
|
||||
<Button
|
||||
plan={Plan.team}
|
||||
isPlanDisabled
|
||||
btnText="Get started"
|
||||
handleGetPayUrl={handleGetPayUrl}
|
||||
/>,
|
||||
)
|
||||
|
||||
const button = screen.getByRole('button', { name: /Get started/i })
|
||||
// Assert
|
||||
expect(button).toBeDisabled()
|
||||
expect(button.className).toContain('cursor-not-allowed')
|
||||
expect(handleGetPayUrl).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Enabled state', () => {
|
||||
test('should invoke handler and render arrow when plan is available', () => {
|
||||
const handleGetPayUrl = jest.fn()
|
||||
// Arrange
|
||||
render(
|
||||
<Button
|
||||
plan={Plan.sandbox}
|
||||
isPlanDisabled={false}
|
||||
btnText="Start now"
|
||||
handleGetPayUrl={handleGetPayUrl}
|
||||
/>,
|
||||
)
|
||||
|
||||
const button = screen.getByRole('button', { name: /Start now/i })
|
||||
// Act
|
||||
fireEvent.click(button)
|
||||
|
||||
// Assert
|
||||
expect(handleGetPayUrl).toHaveBeenCalledTimes(1)
|
||||
expect(button).not.toBeDisabled()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,188 @@
|
||||
import React from 'react'
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import CloudPlanItem from './index'
|
||||
import { Plan } from '../../../type'
|
||||
import { PlanRange } from '../../plan-switcher/plan-range-switcher'
|
||||
import { useAppContext } from '@/context/app-context'
|
||||
import { useAsyncWindowOpen } from '@/hooks/use-async-window-open'
|
||||
import { fetchBillingUrl, fetchSubscriptionUrls } from '@/service/billing'
|
||||
import Toast from '../../../../base/toast'
|
||||
import { ALL_PLANS } from '../../../config'
|
||||
|
||||
jest.mock('../../../../base/toast', () => ({
|
||||
__esModule: true,
|
||||
default: {
|
||||
notify: jest.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
jest.mock('@/context/app-context', () => ({
|
||||
useAppContext: jest.fn(),
|
||||
}))
|
||||
|
||||
jest.mock('@/service/billing', () => ({
|
||||
fetchBillingUrl: jest.fn(),
|
||||
fetchSubscriptionUrls: jest.fn(),
|
||||
}))
|
||||
|
||||
jest.mock('@/hooks/use-async-window-open', () => ({
|
||||
useAsyncWindowOpen: jest.fn(),
|
||||
}))
|
||||
|
||||
jest.mock('../../assets', () => ({
|
||||
Sandbox: () => <div>Sandbox Icon</div>,
|
||||
Professional: () => <div>Professional Icon</div>,
|
||||
Team: () => <div>Team Icon</div>,
|
||||
}))
|
||||
|
||||
const mockUseAppContext = useAppContext as jest.Mock
|
||||
const mockUseAsyncWindowOpen = useAsyncWindowOpen as jest.Mock
|
||||
const mockFetchBillingUrl = fetchBillingUrl as jest.Mock
|
||||
const mockFetchSubscriptionUrls = fetchSubscriptionUrls as jest.Mock
|
||||
const mockToastNotify = Toast.notify as jest.Mock
|
||||
|
||||
let assignedHref = ''
|
||||
const originalLocation = window.location
|
||||
|
||||
beforeAll(() => {
|
||||
Object.defineProperty(window, 'location', {
|
||||
configurable: true,
|
||||
value: {
|
||||
get href() {
|
||||
return assignedHref
|
||||
},
|
||||
set href(value: string) {
|
||||
assignedHref = value
|
||||
},
|
||||
} as unknown as Location,
|
||||
})
|
||||
})
|
||||
|
||||
afterAll(() => {
|
||||
Object.defineProperty(window, 'location', {
|
||||
configurable: true,
|
||||
value: originalLocation,
|
||||
})
|
||||
})
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks()
|
||||
mockUseAppContext.mockReturnValue({ isCurrentWorkspaceManager: true })
|
||||
mockUseAsyncWindowOpen.mockReturnValue(jest.fn(async open => await open()))
|
||||
mockFetchBillingUrl.mockResolvedValue({ url: 'https://billing.example' })
|
||||
mockFetchSubscriptionUrls.mockResolvedValue({ url: 'https://subscription.example' })
|
||||
assignedHref = ''
|
||||
})
|
||||
|
||||
describe('CloudPlanItem', () => {
|
||||
// Static content for each plan
|
||||
describe('Rendering', () => {
|
||||
test('should show plan metadata and free label for sandbox plan', () => {
|
||||
render(
|
||||
<CloudPlanItem
|
||||
plan={Plan.sandbox}
|
||||
currentPlan={Plan.sandbox}
|
||||
planRange={PlanRange.monthly}
|
||||
canPay
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('billing.plans.sandbox.name')).toBeInTheDocument()
|
||||
expect(screen.getByText('billing.plans.sandbox.description')).toBeInTheDocument()
|
||||
expect(screen.getByText('billing.plansCommon.free')).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'billing.plansCommon.currentPlan' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
test('should display yearly pricing with discount when planRange is yearly', () => {
|
||||
render(
|
||||
<CloudPlanItem
|
||||
plan={Plan.professional}
|
||||
currentPlan={Plan.sandbox}
|
||||
planRange={PlanRange.yearly}
|
||||
canPay
|
||||
/>,
|
||||
)
|
||||
|
||||
const professionalPlan = ALL_PLANS[Plan.professional]
|
||||
expect(screen.getByText(`$${professionalPlan.price * 12}`)).toBeInTheDocument()
|
||||
expect(screen.getByText(`$${professionalPlan.price * 10}`)).toBeInTheDocument()
|
||||
expect(screen.getByText(/billing\.plansCommon\.priceTip.*billing\.plansCommon\.year/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
test('should disable CTA when workspace already on higher tier', () => {
|
||||
render(
|
||||
<CloudPlanItem
|
||||
plan={Plan.professional}
|
||||
currentPlan={Plan.team}
|
||||
planRange={PlanRange.monthly}
|
||||
canPay
|
||||
/>,
|
||||
)
|
||||
|
||||
const button = screen.getByRole('button', { name: 'billing.plansCommon.startBuilding' })
|
||||
expect(button).toBeDisabled()
|
||||
})
|
||||
})
|
||||
|
||||
// Payment actions triggered from the CTA
|
||||
describe('Plan purchase flow', () => {
|
||||
test('should show toast when non-manager tries to buy a plan', () => {
|
||||
mockUseAppContext.mockReturnValue({ isCurrentWorkspaceManager: false })
|
||||
|
||||
render(
|
||||
<CloudPlanItem
|
||||
plan={Plan.professional}
|
||||
currentPlan={Plan.sandbox}
|
||||
planRange={PlanRange.monthly}
|
||||
canPay
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'billing.plansCommon.startBuilding' }))
|
||||
expect(mockToastNotify).toHaveBeenCalledWith(expect.objectContaining({
|
||||
type: 'error',
|
||||
message: 'billing.buyPermissionDeniedTip',
|
||||
}))
|
||||
expect(mockFetchBillingUrl).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
test('should open billing portal when upgrading current paid plan', async () => {
|
||||
const openWindow = jest.fn(async (cb: () => Promise<string>) => await cb())
|
||||
mockUseAsyncWindowOpen.mockReturnValue(openWindow)
|
||||
|
||||
render(
|
||||
<CloudPlanItem
|
||||
plan={Plan.professional}
|
||||
currentPlan={Plan.professional}
|
||||
planRange={PlanRange.monthly}
|
||||
canPay
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'billing.plansCommon.currentPlan' }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockFetchBillingUrl).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
expect(openWindow).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
test('should redirect to subscription url when selecting a new paid plan', async () => {
|
||||
render(
|
||||
<CloudPlanItem
|
||||
plan={Plan.professional}
|
||||
currentPlan={Plan.sandbox}
|
||||
planRange={PlanRange.monthly}
|
||||
canPay
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'billing.plansCommon.startBuilding' }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockFetchSubscriptionUrls).toHaveBeenCalledWith(Plan.professional, 'month')
|
||||
expect(assignedHref).toBe('https://subscription.example')
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,30 @@
|
||||
import React from 'react'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import List from './index'
|
||||
import { Plan } from '../../../../type'
|
||||
|
||||
describe('CloudPlanItem/List', () => {
|
||||
test('should show sandbox specific quotas', () => {
|
||||
render(<List plan={Plan.sandbox} />)
|
||||
|
||||
expect(screen.getByText('billing.plansCommon.messageRequest.title:{"count":200}')).toBeInTheDocument()
|
||||
expect(screen.getByText('billing.plansCommon.triggerEvents.sandbox:{"count":3000}')).toBeInTheDocument()
|
||||
expect(screen.getByText('billing.plansCommon.startNodes.limited:{"count":2}')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
test('should show professional monthly quotas and tooltips', () => {
|
||||
render(<List plan={Plan.professional} />)
|
||||
|
||||
expect(screen.getByText('billing.plansCommon.messageRequest.titlePerMonth:{"count":5000}')).toBeInTheDocument()
|
||||
expect(screen.getByText('billing.plansCommon.vectorSpaceTooltip')).toBeInTheDocument()
|
||||
expect(screen.getByText('billing.plansCommon.workflowExecution.faster')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
test('should show unlimited messaging details for team plan', () => {
|
||||
render(<List plan={Plan.team} />)
|
||||
|
||||
expect(screen.getByText('billing.plansCommon.triggerEvents.unlimited')).toBeInTheDocument()
|
||||
expect(screen.getByText('billing.plansCommon.workflowExecution.priority')).toBeInTheDocument()
|
||||
expect(screen.getByText('billing.plansCommon.unlimitedApiRate')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,87 @@
|
||||
import React from 'react'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import Plans from './index'
|
||||
import { Plan, type UsagePlanInfo } from '../../type'
|
||||
import { PlanRange } from '../plan-switcher/plan-range-switcher'
|
||||
|
||||
jest.mock('./cloud-plan-item', () => ({
|
||||
__esModule: true,
|
||||
default: jest.fn(props => (
|
||||
<div data-testid={`cloud-plan-${props.plan}`} data-current-plan={props.currentPlan}>
|
||||
Cloud {props.plan}
|
||||
</div>
|
||||
)),
|
||||
}))
|
||||
|
||||
jest.mock('./self-hosted-plan-item', () => ({
|
||||
__esModule: true,
|
||||
default: jest.fn(props => (
|
||||
<div data-testid={`self-plan-${props.plan}`}>
|
||||
Self {props.plan}
|
||||
</div>
|
||||
)),
|
||||
}))
|
||||
|
||||
const buildPlan = (type: Plan) => {
|
||||
const usage: UsagePlanInfo = {
|
||||
buildApps: 0,
|
||||
teamMembers: 0,
|
||||
annotatedResponse: 0,
|
||||
documentsUploadQuota: 0,
|
||||
apiRateLimit: 0,
|
||||
triggerEvents: 0,
|
||||
vectorSpace: 0,
|
||||
}
|
||||
return {
|
||||
type,
|
||||
usage,
|
||||
total: usage,
|
||||
}
|
||||
}
|
||||
|
||||
describe('Plans', () => {
|
||||
// Cloud plans visible only when currentPlan is cloud
|
||||
describe('Cloud plan rendering', () => {
|
||||
test('should render sandbox, professional, and team cloud plans when workspace is cloud', () => {
|
||||
render(
|
||||
<Plans
|
||||
plan={buildPlan(Plan.enterprise)}
|
||||
currentPlan="cloud"
|
||||
planRange={PlanRange.monthly}
|
||||
canPay
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByTestId('cloud-plan-sandbox')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('cloud-plan-professional')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('cloud-plan-team')).toBeInTheDocument()
|
||||
|
||||
const cloudPlanItem = jest.requireMock('./cloud-plan-item').default as jest.Mock
|
||||
const firstCallProps = cloudPlanItem.mock.calls[0][0]
|
||||
expect(firstCallProps.plan).toBe(Plan.sandbox)
|
||||
// Enterprise should be normalized to team when passed down
|
||||
expect(firstCallProps.currentPlan).toBe(Plan.team)
|
||||
})
|
||||
})
|
||||
|
||||
// Self-hosted plans visible for self-managed workspaces
|
||||
describe('Self-hosted plan rendering', () => {
|
||||
test('should render all self-hosted plans when workspace type is self-hosted', () => {
|
||||
render(
|
||||
<Plans
|
||||
plan={buildPlan(Plan.sandbox)}
|
||||
currentPlan="self"
|
||||
planRange={PlanRange.yearly}
|
||||
canPay={false}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByTestId('self-plan-community')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('self-plan-premium')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('self-plan-enterprise')).toBeInTheDocument()
|
||||
|
||||
const selfPlanItem = jest.requireMock('./self-hosted-plan-item').default as jest.Mock
|
||||
expect(selfPlanItem).toHaveBeenCalledTimes(3)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,61 @@
|
||||
import React from 'react'
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import Button from './button'
|
||||
import { SelfHostedPlan } from '../../../type'
|
||||
import useTheme from '@/hooks/use-theme'
|
||||
import { Theme } from '@/types/app'
|
||||
|
||||
jest.mock('@/hooks/use-theme')
|
||||
|
||||
jest.mock('@/app/components/base/icons/src/public/billing', () => ({
|
||||
AwsMarketplaceLight: () => <div>AwsMarketplaceLight</div>,
|
||||
AwsMarketplaceDark: () => <div>AwsMarketplaceDark</div>,
|
||||
}))
|
||||
|
||||
const mockUseTheme = useTheme as jest.MockedFunction<typeof useTheme>
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks()
|
||||
mockUseTheme.mockReturnValue({ theme: Theme.light } as unknown as ReturnType<typeof useTheme>)
|
||||
})
|
||||
|
||||
describe('SelfHostedPlanButton', () => {
|
||||
test('should invoke handler when clicked', () => {
|
||||
const handleGetPayUrl = jest.fn()
|
||||
render(
|
||||
<Button
|
||||
plan={SelfHostedPlan.community}
|
||||
handleGetPayUrl={handleGetPayUrl}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'billing.plans.community.btnText' }))
|
||||
expect(handleGetPayUrl).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
test('should render AWS marketplace badge for premium plan in light theme', () => {
|
||||
const handleGetPayUrl = jest.fn()
|
||||
|
||||
render(
|
||||
<Button
|
||||
plan={SelfHostedPlan.premium}
|
||||
handleGetPayUrl={handleGetPayUrl}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('AwsMarketplaceLight')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
test('should switch to dark AWS badge in dark theme', () => {
|
||||
mockUseTheme.mockReturnValue({ theme: Theme.dark } as unknown as ReturnType<typeof useTheme>)
|
||||
|
||||
render(
|
||||
<Button
|
||||
plan={SelfHostedPlan.premium}
|
||||
handleGetPayUrl={jest.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('AwsMarketplaceDark')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,143 @@
|
||||
import React from 'react'
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import SelfHostedPlanItem from './index'
|
||||
import { SelfHostedPlan } from '../../../type'
|
||||
import { contactSalesUrl, getStartedWithCommunityUrl, getWithPremiumUrl } from '../../../config'
|
||||
import { useAppContext } from '@/context/app-context'
|
||||
import Toast from '../../../../base/toast'
|
||||
|
||||
const featuresTranslations: Record<string, string[]> = {
|
||||
'billing.plans.community.features': ['community-feature-1', 'community-feature-2'],
|
||||
'billing.plans.premium.features': ['premium-feature-1'],
|
||||
'billing.plans.enterprise.features': ['enterprise-feature-1'],
|
||||
}
|
||||
|
||||
jest.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string, options?: Record<string, unknown>) => {
|
||||
if (options?.returnObjects)
|
||||
return featuresTranslations[key] || []
|
||||
return key
|
||||
},
|
||||
}),
|
||||
Trans: ({ i18nKey }: { i18nKey: string }) => <span>{i18nKey}</span>,
|
||||
}))
|
||||
|
||||
jest.mock('../../../../base/toast', () => ({
|
||||
__esModule: true,
|
||||
default: {
|
||||
notify: jest.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
jest.mock('@/context/app-context', () => ({
|
||||
useAppContext: jest.fn(),
|
||||
}))
|
||||
|
||||
jest.mock('../../assets', () => ({
|
||||
Community: () => <div>Community Icon</div>,
|
||||
Premium: () => <div>Premium Icon</div>,
|
||||
Enterprise: () => <div>Enterprise Icon</div>,
|
||||
PremiumNoise: () => <div>PremiumNoise</div>,
|
||||
EnterpriseNoise: () => <div>EnterpriseNoise</div>,
|
||||
}))
|
||||
|
||||
jest.mock('@/app/components/base/icons/src/public/billing', () => ({
|
||||
Azure: () => <div>Azure</div>,
|
||||
GoogleCloud: () => <div>Google Cloud</div>,
|
||||
AwsMarketplaceDark: () => <div>AwsMarketplaceDark</div>,
|
||||
AwsMarketplaceLight: () => <div>AwsMarketplaceLight</div>,
|
||||
}))
|
||||
|
||||
const mockUseAppContext = useAppContext as jest.Mock
|
||||
const mockToastNotify = Toast.notify as jest.Mock
|
||||
|
||||
let assignedHref = ''
|
||||
const originalLocation = window.location
|
||||
|
||||
beforeAll(() => {
|
||||
Object.defineProperty(window, 'location', {
|
||||
configurable: true,
|
||||
value: {
|
||||
get href() {
|
||||
return assignedHref
|
||||
},
|
||||
set href(value: string) {
|
||||
assignedHref = value
|
||||
},
|
||||
} as unknown as Location,
|
||||
})
|
||||
})
|
||||
|
||||
afterAll(() => {
|
||||
Object.defineProperty(window, 'location', {
|
||||
configurable: true,
|
||||
value: originalLocation,
|
||||
})
|
||||
})
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks()
|
||||
mockUseAppContext.mockReturnValue({ isCurrentWorkspaceManager: true })
|
||||
assignedHref = ''
|
||||
})
|
||||
|
||||
describe('SelfHostedPlanItem', () => {
|
||||
// Copy rendering for each plan
|
||||
describe('Rendering', () => {
|
||||
test('should display community plan info', () => {
|
||||
render(<SelfHostedPlanItem plan={SelfHostedPlan.community} />)
|
||||
|
||||
expect(screen.getByText('billing.plans.community.name')).toBeInTheDocument()
|
||||
expect(screen.getByText('billing.plans.community.description')).toBeInTheDocument()
|
||||
expect(screen.getByText('billing.plans.community.price')).toBeInTheDocument()
|
||||
expect(screen.getByText('billing.plans.community.includesTitle')).toBeInTheDocument()
|
||||
expect(screen.getByText('community-feature-1')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
test('should show premium extras such as cloud provider notice', () => {
|
||||
render(<SelfHostedPlanItem plan={SelfHostedPlan.premium} />)
|
||||
|
||||
expect(screen.getByText('billing.plans.premium.price')).toBeInTheDocument()
|
||||
expect(screen.getByText('billing.plans.premium.comingSoon')).toBeInTheDocument()
|
||||
expect(screen.getByText('Azure')).toBeInTheDocument()
|
||||
expect(screen.getByText('Google Cloud')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// CTA behavior for each plan
|
||||
describe('CTA interactions', () => {
|
||||
test('should show toast when non-manager tries to proceed', () => {
|
||||
mockUseAppContext.mockReturnValue({ isCurrentWorkspaceManager: false })
|
||||
|
||||
render(<SelfHostedPlanItem plan={SelfHostedPlan.premium} />)
|
||||
fireEvent.click(screen.getByRole('button', { name: /billing\.plans\.premium\.btnText/ }))
|
||||
|
||||
expect(mockToastNotify).toHaveBeenCalledWith(expect.objectContaining({
|
||||
type: 'error',
|
||||
message: 'billing.buyPermissionDeniedTip',
|
||||
}))
|
||||
})
|
||||
|
||||
test('should redirect to community url when community plan button clicked', () => {
|
||||
render(<SelfHostedPlanItem plan={SelfHostedPlan.community} />)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'billing.plans.community.btnText' }))
|
||||
expect(assignedHref).toBe(getStartedWithCommunityUrl)
|
||||
})
|
||||
|
||||
test('should redirect to premium marketplace url when premium button clicked', () => {
|
||||
render(<SelfHostedPlanItem plan={SelfHostedPlan.premium} />)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /billing\.plans\.premium\.btnText/ }))
|
||||
expect(assignedHref).toBe(getWithPremiumUrl)
|
||||
})
|
||||
|
||||
test('should redirect to contact sales form when enterprise button clicked', () => {
|
||||
render(<SelfHostedPlanItem plan={SelfHostedPlan.enterprise} />)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'billing.plans.enterprise.btnText' }))
|
||||
expect(assignedHref).toBe(contactSalesUrl)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,25 @@
|
||||
import React from 'react'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import List from './index'
|
||||
import { SelfHostedPlan } from '@/app/components/billing/type'
|
||||
|
||||
jest.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string, options?: Record<string, unknown>) => {
|
||||
if (options?.returnObjects)
|
||||
return ['Feature A', 'Feature B']
|
||||
return key
|
||||
},
|
||||
}),
|
||||
Trans: ({ i18nKey }: { i18nKey: string }) => <span>{i18nKey}</span>,
|
||||
}))
|
||||
|
||||
describe('SelfHostedPlanItem/List', () => {
|
||||
test('should render plan info', () => {
|
||||
render(<List plan={SelfHostedPlan.community} />)
|
||||
|
||||
expect(screen.getByText('billing.plans.community.includesTitle')).toBeInTheDocument()
|
||||
expect(screen.getByText('Feature A')).toBeInTheDocument()
|
||||
expect(screen.getByText('Feature B')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,12 @@
|
||||
import React from 'react'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import Item from './item'
|
||||
|
||||
describe('SelfHostedPlanItem/List/Item', () => {
|
||||
test('should display provided feature label', () => {
|
||||
const { container } = render(<Item label="Dedicated support" />)
|
||||
|
||||
expect(screen.getByText('Dedicated support')).toBeInTheDocument()
|
||||
expect(container.querySelector('svg')).not.toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -29,7 +29,6 @@ const NotionPagePreview = ({
|
||||
return
|
||||
try {
|
||||
const res = await fetchNotionPagePreview({
|
||||
workspaceID: currentPage.workspace_id,
|
||||
pageID: currentPage.page_id,
|
||||
pageType: currentPage.type,
|
||||
credentialID: notionCredentialId,
|
||||
|
||||
+8
-2
@@ -75,11 +75,17 @@ const OnlineDocuments = ({
|
||||
|
||||
const getOnlineDocuments = useCallback(async () => {
|
||||
const { currentCredentialId } = dataSourceStore.getState()
|
||||
// Convert datasource_parameters to inputs format for the API
|
||||
const inputs = Object.entries(nodeData.datasource_parameters || {}).reduce((acc, [key, value]) => {
|
||||
acc[key] = typeof value === 'object' && value !== null && 'value' in value ? value.value : value
|
||||
return acc
|
||||
}, {} as Record<string, any>)
|
||||
|
||||
ssePost(
|
||||
datasourceNodeRunURL,
|
||||
{
|
||||
body: {
|
||||
inputs: {},
|
||||
inputs,
|
||||
credential_id: currentCredentialId,
|
||||
datasource_type: DatasourceType.onlineDocument,
|
||||
},
|
||||
@@ -97,7 +103,7 @@ const OnlineDocuments = ({
|
||||
},
|
||||
},
|
||||
)
|
||||
}, [dataSourceStore, datasourceNodeRunURL])
|
||||
}, [dataSourceStore, datasourceNodeRunURL, nodeData.datasource_parameters])
|
||||
|
||||
useEffect(() => {
|
||||
if (!currentCredentialId) return
|
||||
|
||||
@@ -62,7 +62,7 @@ type CurrChildChunkType = {
|
||||
showModal: boolean
|
||||
}
|
||||
|
||||
type SegmentListContextValue = {
|
||||
export type SegmentListContextValue = {
|
||||
isCollapsed: boolean
|
||||
fullScreen: boolean
|
||||
toggleFullScreen: (fullscreen?: boolean) => void
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -129,6 +129,7 @@ const SegmentCard: FC<ISegmentCardProps> = ({
|
||||
|
||||
return (
|
||||
<div
|
||||
data-testid="segment-card"
|
||||
className={cn(
|
||||
'chunk-card group/card w-full rounded-xl px-3',
|
||||
isFullDocMode ? '' : 'pb-2 pt-2.5 hover:bg-dataset-chunk-detail-card-hover-bg',
|
||||
@@ -172,6 +173,7 @@ const SegmentCard: FC<ISegmentCardProps> = ({
|
||||
popupClassName='text-text-secondary system-xs-medium'
|
||||
>
|
||||
<div
|
||||
data-testid="segment-edit-button"
|
||||
className='flex h-6 w-6 shrink-0 cursor-pointer items-center justify-center rounded-lg hover:bg-state-base-hover'
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
@@ -184,7 +186,9 @@ const SegmentCard: FC<ISegmentCardProps> = ({
|
||||
popupContent='Delete'
|
||||
popupClassName='text-text-secondary system-xs-medium'
|
||||
>
|
||||
<div className='group/delete flex h-6 w-6 shrink-0 cursor-pointer items-center justify-center rounded-lg hover:bg-state-destructive-hover'
|
||||
<div
|
||||
data-testid="segment-delete-button"
|
||||
className='group/delete flex h-6 w-6 shrink-0 cursor-pointer items-center justify-center rounded-lg hover:bg-state-destructive-hover'
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
setShowModal(true)
|
||||
|
||||
+1
-1
@@ -10,7 +10,7 @@ import {
|
||||
const ParentChunkCardSkelton = () => {
|
||||
const { t } = useTranslation()
|
||||
return (
|
||||
<div className='flex flex-col pb-2'>
|
||||
<div data-testid='parent-chunk-card-skeleton' className='flex flex-col pb-2'>
|
||||
<SkeletonContainer className='gap-y-0 p-1 pb-0'>
|
||||
<SkeletonContainer className='gap-y-0.5 px-2 pt-1.5'>
|
||||
<SkeletonRow className='py-0.5'>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { ChunkingMode, ParentMode } from '@/models/datasets'
|
||||
import { createContext, useContextSelector } from 'use-context-selector'
|
||||
|
||||
type DocumentContextValue = {
|
||||
export type DocumentContextValue = {
|
||||
datasetId?: string
|
||||
documentId?: string
|
||||
docForm?: ChunkingMode
|
||||
|
||||
+786
@@ -0,0 +1,786 @@
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import PipelineSettings from './index'
|
||||
import { DatasourceType } from '@/models/pipeline'
|
||||
import type { PipelineExecutionLogResponse } from '@/models/pipeline'
|
||||
|
||||
// Mock i18n
|
||||
jest.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string) => key,
|
||||
}),
|
||||
}))
|
||||
|
||||
// Mock Next.js router
|
||||
const mockPush = jest.fn()
|
||||
const mockBack = jest.fn()
|
||||
jest.mock('next/navigation', () => ({
|
||||
useRouter: () => ({
|
||||
push: mockPush,
|
||||
back: mockBack,
|
||||
}),
|
||||
}))
|
||||
|
||||
// Mock dataset detail context
|
||||
const mockPipelineId = 'pipeline-123'
|
||||
jest.mock('@/context/dataset-detail', () => ({
|
||||
useDatasetDetailContextWithSelector: (selector: (state: { dataset: { pipeline_id: string; doc_form: string } }) => unknown) =>
|
||||
selector({ dataset: { pipeline_id: mockPipelineId, doc_form: 'text_model' } }),
|
||||
}))
|
||||
|
||||
// Mock API hooks for PipelineSettings
|
||||
const mockUsePipelineExecutionLog = jest.fn()
|
||||
const mockMutateAsync = jest.fn()
|
||||
const mockUseRunPublishedPipeline = jest.fn()
|
||||
jest.mock('@/service/use-pipeline', () => ({
|
||||
usePipelineExecutionLog: (params: { dataset_id: string; document_id: string }) => mockUsePipelineExecutionLog(params),
|
||||
useRunPublishedPipeline: () => mockUseRunPublishedPipeline(),
|
||||
// For ProcessDocuments component
|
||||
usePublishedPipelineProcessingParams: () => ({
|
||||
data: { variables: [] },
|
||||
isFetching: false,
|
||||
}),
|
||||
}))
|
||||
|
||||
// Mock document invalidation hooks
|
||||
const mockInvalidDocumentList = jest.fn()
|
||||
const mockInvalidDocumentDetail = jest.fn()
|
||||
jest.mock('@/service/knowledge/use-document', () => ({
|
||||
useInvalidDocumentList: () => mockInvalidDocumentList,
|
||||
useInvalidDocumentDetail: () => mockInvalidDocumentDetail,
|
||||
}))
|
||||
|
||||
// Mock Form component in ProcessDocuments - internal dependencies are too complex
|
||||
jest.mock('../../../create-from-pipeline/process-documents/form', () => {
|
||||
return function MockForm({
|
||||
ref,
|
||||
initialData,
|
||||
configurations,
|
||||
onSubmit,
|
||||
onPreview,
|
||||
isRunning,
|
||||
}: {
|
||||
ref: React.RefObject<{ submit: () => void }>
|
||||
initialData: Record<string, unknown>
|
||||
configurations: Array<{ variable: string; label: string; type: string }>
|
||||
schema: unknown
|
||||
onSubmit: (data: Record<string, unknown>) => void
|
||||
onPreview: () => void
|
||||
isRunning: boolean
|
||||
}) {
|
||||
if (ref && typeof ref === 'object' && 'current' in ref) {
|
||||
(ref as React.MutableRefObject<{ submit: () => void }>).current = {
|
||||
submit: () => onSubmit(initialData),
|
||||
}
|
||||
}
|
||||
return (
|
||||
<form
|
||||
data-testid="process-form"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault()
|
||||
onSubmit(initialData)
|
||||
}}
|
||||
>
|
||||
{configurations.map((config, index) => (
|
||||
<div key={index} data-testid={`field-${config.variable}`}>
|
||||
<label>{config.label}</label>
|
||||
</div>
|
||||
))}
|
||||
<button type="button" data-testid="preview-btn" onClick={onPreview} disabled={isRunning}>
|
||||
Preview
|
||||
</button>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
// Mock ChunkPreview - has complex internal state and many dependencies
|
||||
jest.mock('../../../create-from-pipeline/preview/chunk-preview', () => {
|
||||
return function MockChunkPreview({
|
||||
dataSourceType,
|
||||
localFiles,
|
||||
onlineDocuments,
|
||||
websitePages,
|
||||
onlineDriveFiles,
|
||||
isIdle,
|
||||
isPending,
|
||||
estimateData,
|
||||
}: {
|
||||
dataSourceType: string
|
||||
localFiles: unknown[]
|
||||
onlineDocuments: unknown[]
|
||||
websitePages: unknown[]
|
||||
onlineDriveFiles: unknown[]
|
||||
isIdle: boolean
|
||||
isPending: boolean
|
||||
estimateData: unknown
|
||||
}) {
|
||||
return (
|
||||
<div data-testid="chunk-preview">
|
||||
<span data-testid="datasource-type">{dataSourceType}</span>
|
||||
<span data-testid="local-files-count">{localFiles.length}</span>
|
||||
<span data-testid="online-documents-count">{onlineDocuments.length}</span>
|
||||
<span data-testid="website-pages-count">{websitePages.length}</span>
|
||||
<span data-testid="online-drive-files-count">{onlineDriveFiles.length}</span>
|
||||
<span data-testid="is-idle">{String(isIdle)}</span>
|
||||
<span data-testid="is-pending">{String(isPending)}</span>
|
||||
<span data-testid="has-estimate-data">{String(!!estimateData)}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
// Test utilities
|
||||
const createQueryClient = () =>
|
||||
new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: { retry: false },
|
||||
mutations: { retry: false },
|
||||
},
|
||||
})
|
||||
|
||||
const renderWithProviders = (ui: React.ReactElement) => {
|
||||
const queryClient = createQueryClient()
|
||||
return render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
{ui}
|
||||
</QueryClientProvider>,
|
||||
)
|
||||
}
|
||||
|
||||
// Factory functions for test data
|
||||
const createMockExecutionLogResponse = (
|
||||
overrides: Partial<PipelineExecutionLogResponse> = {},
|
||||
): PipelineExecutionLogResponse => ({
|
||||
datasource_type: DatasourceType.localFile,
|
||||
input_data: { chunk_size: '100' },
|
||||
datasource_node_id: 'datasource-node-1',
|
||||
datasource_info: {
|
||||
related_id: 'file-1',
|
||||
name: 'test-file.pdf',
|
||||
extension: 'pdf',
|
||||
},
|
||||
...overrides,
|
||||
})
|
||||
|
||||
const createDefaultProps = () => ({
|
||||
datasetId: 'dataset-123',
|
||||
documentId: 'document-456',
|
||||
})
|
||||
|
||||
describe('PipelineSettings', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks()
|
||||
mockPush.mockClear()
|
||||
mockBack.mockClear()
|
||||
mockMutateAsync.mockClear()
|
||||
mockInvalidDocumentList.mockClear()
|
||||
mockInvalidDocumentDetail.mockClear()
|
||||
|
||||
// Default: successful data fetch
|
||||
mockUsePipelineExecutionLog.mockReturnValue({
|
||||
data: createMockExecutionLogResponse(),
|
||||
isFetching: false,
|
||||
isError: false,
|
||||
})
|
||||
|
||||
// Default: useRunPublishedPipeline mock
|
||||
mockUseRunPublishedPipeline.mockReturnValue({
|
||||
mutateAsync: mockMutateAsync,
|
||||
isIdle: true,
|
||||
isPending: false,
|
||||
})
|
||||
})
|
||||
|
||||
// ==================== Rendering Tests ====================
|
||||
// Test basic rendering with real components
|
||||
describe('Rendering', () => {
|
||||
it('should render without crashing when data is loaded', () => {
|
||||
// Arrange
|
||||
const props = createDefaultProps()
|
||||
|
||||
// Act
|
||||
renderWithProviders(<PipelineSettings {...props} />)
|
||||
|
||||
// Assert - Real LeftHeader should render with correct content
|
||||
expect(screen.getByText('datasetPipeline.documentSettings.title')).toBeInTheDocument()
|
||||
expect(screen.getByText('datasetPipeline.addDocuments.steps.processDocuments')).toBeInTheDocument()
|
||||
// Real ProcessDocuments should render
|
||||
expect(screen.getByTestId('process-form')).toBeInTheDocument()
|
||||
// ChunkPreview should render
|
||||
expect(screen.getByTestId('chunk-preview')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render Loading component when fetching data', () => {
|
||||
// Arrange
|
||||
mockUsePipelineExecutionLog.mockReturnValue({
|
||||
data: undefined,
|
||||
isFetching: true,
|
||||
isError: false,
|
||||
})
|
||||
const props = createDefaultProps()
|
||||
|
||||
// Act
|
||||
renderWithProviders(<PipelineSettings {...props} />)
|
||||
|
||||
// Assert - Loading component should be rendered, not main content
|
||||
expect(screen.queryByText('datasetPipeline.documentSettings.title')).not.toBeInTheDocument()
|
||||
expect(screen.queryByTestId('process-form')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render AppUnavailable when there is an error', () => {
|
||||
// Arrange
|
||||
mockUsePipelineExecutionLog.mockReturnValue({
|
||||
data: undefined,
|
||||
isFetching: false,
|
||||
isError: true,
|
||||
})
|
||||
const props = createDefaultProps()
|
||||
|
||||
// Act
|
||||
renderWithProviders(<PipelineSettings {...props} />)
|
||||
|
||||
// Assert - AppUnavailable should be rendered
|
||||
expect(screen.queryByText('datasetPipeline.documentSettings.title')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render container with correct CSS classes', () => {
|
||||
// Arrange
|
||||
const props = createDefaultProps()
|
||||
|
||||
// Act
|
||||
const { container } = renderWithProviders(<PipelineSettings {...props} />)
|
||||
|
||||
// Assert
|
||||
const mainContainer = container.firstChild as HTMLElement
|
||||
expect(mainContainer).toHaveClass('relative', 'flex', 'min-w-[1024px]')
|
||||
})
|
||||
})
|
||||
|
||||
// ==================== LeftHeader Integration ====================
|
||||
// Test real LeftHeader component behavior
|
||||
describe('LeftHeader Integration', () => {
|
||||
it('should render LeftHeader with title prop', () => {
|
||||
// Arrange
|
||||
const props = createDefaultProps()
|
||||
|
||||
// Act
|
||||
renderWithProviders(<PipelineSettings {...props} />)
|
||||
|
||||
// Assert - LeftHeader displays the title
|
||||
expect(screen.getByText('datasetPipeline.documentSettings.title')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render back button in LeftHeader', () => {
|
||||
// Arrange
|
||||
const props = createDefaultProps()
|
||||
|
||||
// Act
|
||||
renderWithProviders(<PipelineSettings {...props} />)
|
||||
|
||||
// Assert - Back button should exist with proper aria-label
|
||||
const backButton = screen.getByRole('button', { name: 'common.operation.back' })
|
||||
expect(backButton).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should call router.back when back button is clicked', () => {
|
||||
// Arrange
|
||||
const props = createDefaultProps()
|
||||
|
||||
// Act
|
||||
renderWithProviders(<PipelineSettings {...props} />)
|
||||
const backButton = screen.getByRole('button', { name: 'common.operation.back' })
|
||||
fireEvent.click(backButton)
|
||||
|
||||
// Assert
|
||||
expect(mockBack).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
// ==================== Props Testing ====================
|
||||
describe('Props', () => {
|
||||
it('should pass datasetId and documentId to usePipelineExecutionLog', () => {
|
||||
// Arrange
|
||||
const props = { datasetId: 'custom-dataset', documentId: 'custom-document' }
|
||||
|
||||
// Act
|
||||
renderWithProviders(<PipelineSettings {...props} />)
|
||||
|
||||
// Assert
|
||||
expect(mockUsePipelineExecutionLog).toHaveBeenCalledWith({
|
||||
dataset_id: 'custom-dataset',
|
||||
document_id: 'custom-document',
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// ==================== Memoization - Data Transformation ====================
|
||||
describe('Memoization - Data Transformation', () => {
|
||||
it('should transform localFile datasource correctly', () => {
|
||||
// Arrange
|
||||
const mockData = createMockExecutionLogResponse({
|
||||
datasource_type: DatasourceType.localFile,
|
||||
datasource_info: {
|
||||
related_id: 'file-123',
|
||||
name: 'document.pdf',
|
||||
extension: 'pdf',
|
||||
},
|
||||
})
|
||||
mockUsePipelineExecutionLog.mockReturnValue({
|
||||
data: mockData,
|
||||
isFetching: false,
|
||||
isError: false,
|
||||
})
|
||||
const props = createDefaultProps()
|
||||
|
||||
// Act
|
||||
renderWithProviders(<PipelineSettings {...props} />)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByTestId('local-files-count')).toHaveTextContent('1')
|
||||
expect(screen.getByTestId('datasource-type')).toHaveTextContent(DatasourceType.localFile)
|
||||
})
|
||||
|
||||
it('should transform websiteCrawl datasource correctly', () => {
|
||||
// Arrange
|
||||
const mockData = createMockExecutionLogResponse({
|
||||
datasource_type: DatasourceType.websiteCrawl,
|
||||
datasource_info: {
|
||||
content: 'Page content',
|
||||
description: 'Page description',
|
||||
source_url: 'https://example.com/page',
|
||||
title: 'Page Title',
|
||||
},
|
||||
})
|
||||
mockUsePipelineExecutionLog.mockReturnValue({
|
||||
data: mockData,
|
||||
isFetching: false,
|
||||
isError: false,
|
||||
})
|
||||
const props = createDefaultProps()
|
||||
|
||||
// Act
|
||||
renderWithProviders(<PipelineSettings {...props} />)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByTestId('website-pages-count')).toHaveTextContent('1')
|
||||
expect(screen.getByTestId('local-files-count')).toHaveTextContent('0')
|
||||
})
|
||||
|
||||
it('should transform onlineDocument datasource correctly', () => {
|
||||
// Arrange
|
||||
const mockData = createMockExecutionLogResponse({
|
||||
datasource_type: DatasourceType.onlineDocument,
|
||||
datasource_info: {
|
||||
workspace_id: 'workspace-1',
|
||||
page: { page_id: 'page-1', page_name: 'Notion Page' },
|
||||
},
|
||||
})
|
||||
mockUsePipelineExecutionLog.mockReturnValue({
|
||||
data: mockData,
|
||||
isFetching: false,
|
||||
isError: false,
|
||||
})
|
||||
const props = createDefaultProps()
|
||||
|
||||
// Act
|
||||
renderWithProviders(<PipelineSettings {...props} />)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByTestId('online-documents-count')).toHaveTextContent('1')
|
||||
})
|
||||
|
||||
it('should transform onlineDrive datasource correctly', () => {
|
||||
// Arrange
|
||||
const mockData = createMockExecutionLogResponse({
|
||||
datasource_type: DatasourceType.onlineDrive,
|
||||
datasource_info: { id: 'drive-1', type: 'doc', name: 'Google Doc', size: 1024 },
|
||||
})
|
||||
mockUsePipelineExecutionLog.mockReturnValue({
|
||||
data: mockData,
|
||||
isFetching: false,
|
||||
isError: false,
|
||||
})
|
||||
const props = createDefaultProps()
|
||||
|
||||
// Act
|
||||
renderWithProviders(<PipelineSettings {...props} />)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByTestId('online-drive-files-count')).toHaveTextContent('1')
|
||||
})
|
||||
})
|
||||
|
||||
// ==================== User Interactions - Process ====================
|
||||
describe('User Interactions - Process', () => {
|
||||
it('should trigger form submit when process button is clicked', async () => {
|
||||
// Arrange
|
||||
mockMutateAsync.mockResolvedValue({})
|
||||
const props = createDefaultProps()
|
||||
|
||||
// Act
|
||||
renderWithProviders(<PipelineSettings {...props} />)
|
||||
// Find the "Save and Process" button (from real ProcessDocuments > Actions)
|
||||
const processButton = screen.getByRole('button', { name: 'datasetPipeline.operations.saveAndProcess' })
|
||||
fireEvent.click(processButton)
|
||||
|
||||
// Assert
|
||||
await waitFor(() => {
|
||||
expect(mockMutateAsync).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
it('should call handleProcess with is_preview=false', async () => {
|
||||
// Arrange
|
||||
mockMutateAsync.mockResolvedValue({})
|
||||
const props = createDefaultProps()
|
||||
|
||||
// Act
|
||||
renderWithProviders(<PipelineSettings {...props} />)
|
||||
fireEvent.click(screen.getByRole('button', { name: 'datasetPipeline.operations.saveAndProcess' }))
|
||||
|
||||
// Assert
|
||||
await waitFor(() => {
|
||||
expect(mockMutateAsync).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
is_preview: false,
|
||||
pipeline_id: mockPipelineId,
|
||||
original_document_id: 'document-456',
|
||||
}),
|
||||
expect.any(Object),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
it('should navigate to documents list after successful process', async () => {
|
||||
// Arrange
|
||||
mockMutateAsync.mockImplementation((_request, options) => {
|
||||
options?.onSuccess?.()
|
||||
return Promise.resolve({})
|
||||
})
|
||||
const props = createDefaultProps()
|
||||
|
||||
// Act
|
||||
renderWithProviders(<PipelineSettings {...props} />)
|
||||
fireEvent.click(screen.getByRole('button', { name: 'datasetPipeline.operations.saveAndProcess' }))
|
||||
|
||||
// Assert
|
||||
await waitFor(() => {
|
||||
expect(mockPush).toHaveBeenCalledWith('/datasets/dataset-123/documents')
|
||||
})
|
||||
})
|
||||
|
||||
it('should invalidate document cache after successful process', async () => {
|
||||
// Arrange
|
||||
mockMutateAsync.mockImplementation((_request, options) => {
|
||||
options?.onSuccess?.()
|
||||
return Promise.resolve({})
|
||||
})
|
||||
const props = createDefaultProps()
|
||||
|
||||
// Act
|
||||
renderWithProviders(<PipelineSettings {...props} />)
|
||||
fireEvent.click(screen.getByRole('button', { name: 'datasetPipeline.operations.saveAndProcess' }))
|
||||
|
||||
// Assert
|
||||
await waitFor(() => {
|
||||
expect(mockInvalidDocumentList).toHaveBeenCalled()
|
||||
expect(mockInvalidDocumentDetail).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// ==================== User Interactions - Preview ====================
|
||||
describe('User Interactions - Preview', () => {
|
||||
it('should trigger preview when preview button is clicked', async () => {
|
||||
// Arrange
|
||||
mockMutateAsync.mockResolvedValue({ data: { outputs: {} } })
|
||||
const props = createDefaultProps()
|
||||
|
||||
// Act
|
||||
renderWithProviders(<PipelineSettings {...props} />)
|
||||
fireEvent.click(screen.getByTestId('preview-btn'))
|
||||
|
||||
// Assert
|
||||
await waitFor(() => {
|
||||
expect(mockMutateAsync).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
it('should call handlePreviewChunks with is_preview=true', async () => {
|
||||
// Arrange
|
||||
mockMutateAsync.mockResolvedValue({ data: { outputs: {} } })
|
||||
const props = createDefaultProps()
|
||||
|
||||
// Act
|
||||
renderWithProviders(<PipelineSettings {...props} />)
|
||||
fireEvent.click(screen.getByTestId('preview-btn'))
|
||||
|
||||
// Assert
|
||||
await waitFor(() => {
|
||||
expect(mockMutateAsync).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
is_preview: true,
|
||||
pipeline_id: mockPipelineId,
|
||||
}),
|
||||
expect.any(Object),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
it('should update estimateData on successful preview', async () => {
|
||||
// Arrange
|
||||
const mockOutputs = { chunks: [], total_tokens: 50 }
|
||||
mockMutateAsync.mockImplementation((_req, opts) => {
|
||||
opts?.onSuccess?.({ data: { outputs: mockOutputs } })
|
||||
return Promise.resolve({ data: { outputs: mockOutputs } })
|
||||
})
|
||||
const props = createDefaultProps()
|
||||
|
||||
// Act
|
||||
renderWithProviders(<PipelineSettings {...props} />)
|
||||
fireEvent.click(screen.getByTestId('preview-btn'))
|
||||
|
||||
// Assert
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('has-estimate-data')).toHaveTextContent('true')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// ==================== API Integration ====================
|
||||
describe('API Integration', () => {
|
||||
it('should pass correct parameters for preview', async () => {
|
||||
// Arrange
|
||||
const mockData = createMockExecutionLogResponse({
|
||||
datasource_type: DatasourceType.localFile,
|
||||
datasource_node_id: 'node-xyz',
|
||||
datasource_info: { related_id: 'file-1', name: 'test.pdf', extension: 'pdf' },
|
||||
input_data: {},
|
||||
})
|
||||
mockUsePipelineExecutionLog.mockReturnValue({
|
||||
data: mockData,
|
||||
isFetching: false,
|
||||
isError: false,
|
||||
})
|
||||
mockMutateAsync.mockResolvedValue({ data: { outputs: {} } })
|
||||
const props = createDefaultProps()
|
||||
|
||||
// Act
|
||||
renderWithProviders(<PipelineSettings {...props} />)
|
||||
fireEvent.click(screen.getByTestId('preview-btn'))
|
||||
|
||||
// Assert - inputs come from initialData which is transformed by useInitialData
|
||||
// Since usePublishedPipelineProcessingParams returns empty variables, inputs is {}
|
||||
await waitFor(() => {
|
||||
expect(mockMutateAsync).toHaveBeenCalledWith(
|
||||
{
|
||||
pipeline_id: mockPipelineId,
|
||||
inputs: {},
|
||||
start_node_id: 'node-xyz',
|
||||
datasource_type: DatasourceType.localFile,
|
||||
datasource_info_list: [{ related_id: 'file-1', name: 'test.pdf', extension: 'pdf' }],
|
||||
is_preview: true,
|
||||
},
|
||||
expect.any(Object),
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// ==================== Edge Cases ====================
|
||||
describe('Edge Cases', () => {
|
||||
it.each([
|
||||
[DatasourceType.localFile, 'local-files-count', '1'],
|
||||
[DatasourceType.websiteCrawl, 'website-pages-count', '1'],
|
||||
[DatasourceType.onlineDocument, 'online-documents-count', '1'],
|
||||
[DatasourceType.onlineDrive, 'online-drive-files-count', '1'],
|
||||
])('should handle %s datasource type correctly', (datasourceType, testId, expectedCount) => {
|
||||
// Arrange
|
||||
const datasourceInfoMap: Record<DatasourceType, Record<string, unknown>> = {
|
||||
[DatasourceType.localFile]: { related_id: 'f1', name: 'file.pdf', extension: 'pdf' },
|
||||
[DatasourceType.websiteCrawl]: { content: 'c', description: 'd', source_url: 'u', title: 't' },
|
||||
[DatasourceType.onlineDocument]: { workspace_id: 'w1', page: { page_id: 'p1' } },
|
||||
[DatasourceType.onlineDrive]: { id: 'd1', type: 'doc', name: 'n', size: 100 },
|
||||
}
|
||||
|
||||
const mockData = createMockExecutionLogResponse({
|
||||
datasource_type: datasourceType,
|
||||
datasource_info: datasourceInfoMap[datasourceType],
|
||||
})
|
||||
mockUsePipelineExecutionLog.mockReturnValue({
|
||||
data: mockData,
|
||||
isFetching: false,
|
||||
isError: false,
|
||||
})
|
||||
const props = createDefaultProps()
|
||||
|
||||
// Act
|
||||
renderWithProviders(<PipelineSettings {...props} />)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByTestId(testId)).toHaveTextContent(expectedCount)
|
||||
})
|
||||
|
||||
it('should show loading state during initial fetch', () => {
|
||||
// Arrange
|
||||
mockUsePipelineExecutionLog.mockReturnValue({
|
||||
data: undefined,
|
||||
isFetching: true,
|
||||
isError: false,
|
||||
})
|
||||
const props = createDefaultProps()
|
||||
|
||||
// Act
|
||||
renderWithProviders(<PipelineSettings {...props} />)
|
||||
|
||||
// Assert
|
||||
expect(screen.queryByTestId('process-form')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show error state when API fails', () => {
|
||||
// Arrange
|
||||
mockUsePipelineExecutionLog.mockReturnValue({
|
||||
data: undefined,
|
||||
isFetching: false,
|
||||
isError: true,
|
||||
})
|
||||
const props = createDefaultProps()
|
||||
|
||||
// Act
|
||||
renderWithProviders(<PipelineSettings {...props} />)
|
||||
|
||||
// Assert
|
||||
expect(screen.queryByTestId('process-form')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// ==================== State Management ====================
|
||||
describe('State Management', () => {
|
||||
it('should initialize with undefined estimateData', () => {
|
||||
// Arrange
|
||||
const props = createDefaultProps()
|
||||
|
||||
// Act
|
||||
renderWithProviders(<PipelineSettings {...props} />)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByTestId('has-estimate-data')).toHaveTextContent('false')
|
||||
})
|
||||
|
||||
it('should update estimateData after successful preview', async () => {
|
||||
// Arrange
|
||||
const mockEstimateData = { chunks: [], total_tokens: 50 }
|
||||
mockMutateAsync.mockImplementation((_req, opts) => {
|
||||
opts?.onSuccess?.({ data: { outputs: mockEstimateData } })
|
||||
return Promise.resolve({ data: { outputs: mockEstimateData } })
|
||||
})
|
||||
const props = createDefaultProps()
|
||||
|
||||
// Act
|
||||
renderWithProviders(<PipelineSettings {...props} />)
|
||||
fireEvent.click(screen.getByTestId('preview-btn'))
|
||||
|
||||
// Assert
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('has-estimate-data')).toHaveTextContent('true')
|
||||
})
|
||||
})
|
||||
|
||||
it('should set isPreview ref to false when process is clicked', async () => {
|
||||
// Arrange
|
||||
mockMutateAsync.mockResolvedValue({})
|
||||
const props = createDefaultProps()
|
||||
|
||||
// Act
|
||||
renderWithProviders(<PipelineSettings {...props} />)
|
||||
fireEvent.click(screen.getByRole('button', { name: 'datasetPipeline.operations.saveAndProcess' }))
|
||||
|
||||
// Assert
|
||||
await waitFor(() => {
|
||||
expect(mockMutateAsync).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ is_preview: false }),
|
||||
expect.any(Object),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
it('should set isPreview ref to true when preview is clicked', async () => {
|
||||
// Arrange
|
||||
mockMutateAsync.mockResolvedValue({ data: { outputs: {} } })
|
||||
const props = createDefaultProps()
|
||||
|
||||
// Act
|
||||
renderWithProviders(<PipelineSettings {...props} />)
|
||||
fireEvent.click(screen.getByTestId('preview-btn'))
|
||||
|
||||
// Assert
|
||||
await waitFor(() => {
|
||||
expect(mockMutateAsync).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ is_preview: true }),
|
||||
expect.any(Object),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
it('should pass isPending=true to ChunkPreview when preview is pending', async () => {
|
||||
// Arrange - Start with isPending=false so buttons are enabled
|
||||
let isPendingState = false
|
||||
mockUseRunPublishedPipeline.mockImplementation(() => ({
|
||||
mutateAsync: mockMutateAsync,
|
||||
isIdle: !isPendingState,
|
||||
isPending: isPendingState,
|
||||
}))
|
||||
|
||||
// A promise that never resolves to keep the pending state
|
||||
const pendingPromise = new Promise<void>(() => undefined)
|
||||
// When mutateAsync is called, set isPending to true and trigger rerender
|
||||
mockMutateAsync.mockImplementation(() => {
|
||||
isPendingState = true
|
||||
return pendingPromise
|
||||
})
|
||||
|
||||
const props = createDefaultProps()
|
||||
const { rerender } = renderWithProviders(<PipelineSettings {...props} />)
|
||||
|
||||
// Act - Click preview button (sets isPreview.current = true and calls mutateAsync)
|
||||
fireEvent.click(screen.getByTestId('preview-btn'))
|
||||
|
||||
// Update mock and rerender to reflect isPending=true state
|
||||
mockUseRunPublishedPipeline.mockReturnValue({
|
||||
mutateAsync: mockMutateAsync,
|
||||
isIdle: false,
|
||||
isPending: true,
|
||||
})
|
||||
rerender(
|
||||
<QueryClientProvider client={createQueryClient()}>
|
||||
<PipelineSettings {...props} />
|
||||
</QueryClientProvider>,
|
||||
)
|
||||
|
||||
// Assert - isPending && isPreview.current should both be true now
|
||||
expect(screen.getByTestId('is-pending')).toHaveTextContent('true')
|
||||
})
|
||||
|
||||
it('should pass isPending=false to ChunkPreview when process is pending (not preview)', async () => {
|
||||
// Arrange - isPending is true but isPreview.current is false
|
||||
mockUseRunPublishedPipeline.mockReturnValue({
|
||||
mutateAsync: mockMutateAsync,
|
||||
isIdle: false,
|
||||
isPending: true,
|
||||
})
|
||||
mockMutateAsync.mockReturnValue(new Promise<void>(() => undefined))
|
||||
const props = createDefaultProps()
|
||||
|
||||
// Act
|
||||
renderWithProviders(<PipelineSettings {...props} />)
|
||||
// Click process (not preview) to set isPreview.current = false
|
||||
fireEvent.click(screen.getByRole('button', { name: 'datasetPipeline.operations.saveAndProcess' }))
|
||||
|
||||
// Assert - isPending && isPreview.current should be false (true && false = false)
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('is-pending')).toHaveTextContent('false')
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
+1
@@ -31,6 +31,7 @@ const LeftHeader = ({
|
||||
variant='secondary-accent'
|
||||
className='absolute -left-11 top-3.5 size-9 rounded-full p-0'
|
||||
onClick={navigateBack}
|
||||
aria-label={t('common.operation.back')}
|
||||
>
|
||||
<RiArrowLeftLine className='size-5 ' />
|
||||
</Button>
|
||||
|
||||
+573
@@ -0,0 +1,573 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import ProcessDocuments from './index'
|
||||
import { PipelineInputVarType } from '@/models/pipeline'
|
||||
import type { RAGPipelineVariable } from '@/models/pipeline'
|
||||
|
||||
// Mock i18n
|
||||
jest.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string) => key,
|
||||
}),
|
||||
}))
|
||||
|
||||
// Mock dataset detail context - required for useInputVariables hook
|
||||
const mockPipelineId = 'pipeline-123'
|
||||
jest.mock('@/context/dataset-detail', () => ({
|
||||
useDatasetDetailContextWithSelector: (selector: (state: { dataset: { pipeline_id: string } }) => string) =>
|
||||
selector({ dataset: { pipeline_id: mockPipelineId } }),
|
||||
}))
|
||||
|
||||
// Mock API call for pipeline processing params
|
||||
const mockParamsConfig = jest.fn()
|
||||
jest.mock('@/service/use-pipeline', () => ({
|
||||
usePublishedPipelineProcessingParams: () => ({
|
||||
data: mockParamsConfig(),
|
||||
isFetching: false,
|
||||
}),
|
||||
}))
|
||||
|
||||
// Mock Form component - internal dependencies (useAppForm, BaseField) are too complex
|
||||
// Keep the mock minimal and focused on testing the integration
|
||||
jest.mock('../../../../create-from-pipeline/process-documents/form', () => {
|
||||
return function MockForm({
|
||||
ref,
|
||||
initialData,
|
||||
configurations,
|
||||
onSubmit,
|
||||
onPreview,
|
||||
isRunning,
|
||||
}: {
|
||||
ref: React.RefObject<{ submit: () => void }>
|
||||
initialData: Record<string, unknown>
|
||||
configurations: Array<{ variable: string; label: string; type: string }>
|
||||
schema: unknown
|
||||
onSubmit: (data: Record<string, unknown>) => void
|
||||
onPreview: () => void
|
||||
isRunning: boolean
|
||||
}) {
|
||||
// Expose submit method via ref for parent component control
|
||||
if (ref && typeof ref === 'object' && 'current' in ref) {
|
||||
(ref as React.MutableRefObject<{ submit: () => void }>).current = {
|
||||
submit: () => onSubmit(initialData),
|
||||
}
|
||||
}
|
||||
return (
|
||||
<form
|
||||
data-testid="process-form"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault()
|
||||
onSubmit(initialData)
|
||||
}}
|
||||
>
|
||||
{/* Render actual field labels from configurations */}
|
||||
{configurations.map((config, index) => (
|
||||
<div key={index} data-testid={`field-${config.variable}`}>
|
||||
<label>{config.label}</label>
|
||||
<input
|
||||
name={config.variable}
|
||||
defaultValue={String(initialData[config.variable] ?? '')}
|
||||
data-testid={`input-${config.variable}`}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
<button type="button" data-testid="preview-btn" onClick={onPreview} disabled={isRunning}>
|
||||
Preview
|
||||
</button>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
// Test utilities
|
||||
const createQueryClient = () =>
|
||||
new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: { retry: false },
|
||||
mutations: { retry: false },
|
||||
},
|
||||
})
|
||||
|
||||
const renderWithProviders = (ui: React.ReactElement) => {
|
||||
const queryClient = createQueryClient()
|
||||
return render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
{ui}
|
||||
</QueryClientProvider>,
|
||||
)
|
||||
}
|
||||
|
||||
// Factory function for creating mock variables - matches RAGPipelineVariable type
|
||||
const createMockVariable = (overrides: Partial<RAGPipelineVariable> = {}): RAGPipelineVariable => ({
|
||||
belong_to_node_id: 'node-123',
|
||||
type: PipelineInputVarType.textInput,
|
||||
variable: 'test_var',
|
||||
label: 'Test Variable',
|
||||
required: false,
|
||||
...overrides,
|
||||
})
|
||||
|
||||
// Default props factory
|
||||
const createDefaultProps = (overrides: Partial<{
|
||||
datasourceNodeId: string
|
||||
lastRunInputData: Record<string, unknown>
|
||||
isRunning: boolean
|
||||
ref: React.RefObject<{ submit: () => void } | null>
|
||||
onProcess: () => void
|
||||
onPreview: () => void
|
||||
onSubmit: (data: Record<string, unknown>) => void
|
||||
}> = {}) => ({
|
||||
datasourceNodeId: 'node-123',
|
||||
lastRunInputData: {},
|
||||
isRunning: false,
|
||||
ref: { current: null } as React.RefObject<{ submit: () => void } | null>,
|
||||
onProcess: jest.fn(),
|
||||
onPreview: jest.fn(),
|
||||
onSubmit: jest.fn(),
|
||||
...overrides,
|
||||
})
|
||||
|
||||
describe('ProcessDocuments', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks()
|
||||
// Default: return empty variables
|
||||
mockParamsConfig.mockReturnValue({ variables: [] })
|
||||
})
|
||||
|
||||
// ==================== Rendering Tests ====================
|
||||
// Test basic rendering and component structure
|
||||
describe('Rendering', () => {
|
||||
it('should render without crashing', () => {
|
||||
// Arrange
|
||||
const props = createDefaultProps()
|
||||
|
||||
// Act
|
||||
renderWithProviders(<ProcessDocuments {...props} />)
|
||||
|
||||
// Assert - verify both Form and Actions are rendered
|
||||
expect(screen.getByTestId('process-form')).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'datasetPipeline.operations.saveAndProcess' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render with correct container structure', () => {
|
||||
// Arrange
|
||||
const props = createDefaultProps()
|
||||
|
||||
// Act
|
||||
const { container } = renderWithProviders(<ProcessDocuments {...props} />)
|
||||
|
||||
// Assert
|
||||
const wrapper = container.firstChild as HTMLElement
|
||||
expect(wrapper).toHaveClass('flex', 'flex-col', 'gap-y-4', 'pt-4')
|
||||
})
|
||||
|
||||
it('should render form fields based on variables configuration', () => {
|
||||
// Arrange
|
||||
const variables: RAGPipelineVariable[] = [
|
||||
createMockVariable({ variable: 'chunk_size', label: 'Chunk Size', type: PipelineInputVarType.number }),
|
||||
createMockVariable({ variable: 'separator', label: 'Separator', type: PipelineInputVarType.textInput }),
|
||||
]
|
||||
mockParamsConfig.mockReturnValue({ variables })
|
||||
const props = createDefaultProps()
|
||||
|
||||
// Act
|
||||
renderWithProviders(<ProcessDocuments {...props} />)
|
||||
|
||||
// Assert - real hooks transform variables to configurations
|
||||
expect(screen.getByTestId('field-chunk_size')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('field-separator')).toBeInTheDocument()
|
||||
expect(screen.getByText('Chunk Size')).toBeInTheDocument()
|
||||
expect(screen.getByText('Separator')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// ==================== Props Testing ====================
|
||||
// Test how component behaves with different prop values
|
||||
describe('Props', () => {
|
||||
describe('lastRunInputData', () => {
|
||||
it('should use lastRunInputData as initial form values', () => {
|
||||
// Arrange
|
||||
const variables: RAGPipelineVariable[] = [
|
||||
createMockVariable({ variable: 'chunk_size', label: 'Chunk Size', type: PipelineInputVarType.number, default_value: '100' }),
|
||||
]
|
||||
mockParamsConfig.mockReturnValue({ variables })
|
||||
const lastRunInputData = { chunk_size: 500 }
|
||||
const props = createDefaultProps({ lastRunInputData })
|
||||
|
||||
// Act
|
||||
renderWithProviders(<ProcessDocuments {...props} />)
|
||||
|
||||
// Assert - lastRunInputData should override default_value
|
||||
const input = screen.getByTestId('input-chunk_size') as HTMLInputElement
|
||||
expect(input.defaultValue).toBe('500')
|
||||
})
|
||||
|
||||
it('should use default_value when lastRunInputData is empty', () => {
|
||||
// Arrange
|
||||
const variables: RAGPipelineVariable[] = [
|
||||
createMockVariable({ variable: 'chunk_size', label: 'Chunk Size', type: PipelineInputVarType.number, default_value: '100' }),
|
||||
]
|
||||
mockParamsConfig.mockReturnValue({ variables })
|
||||
const props = createDefaultProps({ lastRunInputData: {} })
|
||||
|
||||
// Act
|
||||
renderWithProviders(<ProcessDocuments {...props} />)
|
||||
|
||||
// Assert
|
||||
const input = screen.getByTestId('input-chunk_size') as HTMLInputElement
|
||||
expect(input.value).toBe('100')
|
||||
})
|
||||
})
|
||||
|
||||
describe('isRunning', () => {
|
||||
it('should enable Actions button when isRunning is false', () => {
|
||||
// Arrange
|
||||
const props = createDefaultProps({ isRunning: false })
|
||||
|
||||
// Act
|
||||
renderWithProviders(<ProcessDocuments {...props} />)
|
||||
|
||||
// Assert
|
||||
const processButton = screen.getByRole('button', { name: 'datasetPipeline.operations.saveAndProcess' })
|
||||
expect(processButton).not.toBeDisabled()
|
||||
})
|
||||
|
||||
it('should disable Actions button when isRunning is true', () => {
|
||||
// Arrange
|
||||
const props = createDefaultProps({ isRunning: true })
|
||||
|
||||
// Act
|
||||
renderWithProviders(<ProcessDocuments {...props} />)
|
||||
|
||||
// Assert
|
||||
const processButton = screen.getByRole('button', { name: 'datasetPipeline.operations.saveAndProcess' })
|
||||
expect(processButton).toBeDisabled()
|
||||
})
|
||||
|
||||
it('should disable preview button when isRunning is true', () => {
|
||||
// Arrange
|
||||
const props = createDefaultProps({ isRunning: true })
|
||||
|
||||
// Act
|
||||
renderWithProviders(<ProcessDocuments {...props} />)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByTestId('preview-btn')).toBeDisabled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('ref', () => {
|
||||
it('should expose submit method via ref', () => {
|
||||
// Arrange
|
||||
const ref = { current: null } as React.RefObject<{ submit: () => void } | null>
|
||||
const onSubmit = jest.fn()
|
||||
const props = createDefaultProps({ ref, onSubmit })
|
||||
|
||||
// Act
|
||||
renderWithProviders(<ProcessDocuments {...props} />)
|
||||
|
||||
// Assert
|
||||
expect(ref.current).not.toBeNull()
|
||||
expect(typeof ref.current?.submit).toBe('function')
|
||||
|
||||
// Act - call submit via ref
|
||||
ref.current?.submit()
|
||||
|
||||
// Assert - onSubmit should be called
|
||||
expect(onSubmit).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// ==================== User Interactions ====================
|
||||
// Test event handlers and user interactions
|
||||
describe('User Interactions', () => {
|
||||
describe('onProcess', () => {
|
||||
it('should call onProcess when Save and Process button is clicked', () => {
|
||||
// Arrange
|
||||
const onProcess = jest.fn()
|
||||
const props = createDefaultProps({ onProcess })
|
||||
|
||||
// Act
|
||||
renderWithProviders(<ProcessDocuments {...props} />)
|
||||
fireEvent.click(screen.getByRole('button', { name: 'datasetPipeline.operations.saveAndProcess' }))
|
||||
|
||||
// Assert
|
||||
expect(onProcess).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should not call onProcess when button is disabled due to isRunning', () => {
|
||||
// Arrange
|
||||
const onProcess = jest.fn()
|
||||
const props = createDefaultProps({ onProcess, isRunning: true })
|
||||
|
||||
// Act
|
||||
renderWithProviders(<ProcessDocuments {...props} />)
|
||||
fireEvent.click(screen.getByRole('button', { name: 'datasetPipeline.operations.saveAndProcess' }))
|
||||
|
||||
// Assert
|
||||
expect(onProcess).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('onPreview', () => {
|
||||
it('should call onPreview when preview button is clicked', () => {
|
||||
// Arrange
|
||||
const onPreview = jest.fn()
|
||||
const props = createDefaultProps({ onPreview })
|
||||
|
||||
// Act
|
||||
renderWithProviders(<ProcessDocuments {...props} />)
|
||||
fireEvent.click(screen.getByTestId('preview-btn'))
|
||||
|
||||
// Assert
|
||||
expect(onPreview).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('onSubmit', () => {
|
||||
it('should call onSubmit with form data when form is submitted', () => {
|
||||
// Arrange
|
||||
const variables: RAGPipelineVariable[] = [
|
||||
createMockVariable({ variable: 'chunk_size', label: 'Chunk Size', type: PipelineInputVarType.number, default_value: '100' }),
|
||||
]
|
||||
mockParamsConfig.mockReturnValue({ variables })
|
||||
const onSubmit = jest.fn()
|
||||
const props = createDefaultProps({ onSubmit })
|
||||
|
||||
// Act
|
||||
renderWithProviders(<ProcessDocuments {...props} />)
|
||||
fireEvent.submit(screen.getByTestId('process-form'))
|
||||
|
||||
// Assert - should submit with initial data transformed by real hooks
|
||||
// Note: default_value is string type, so the value remains as string
|
||||
expect(onSubmit).toHaveBeenCalledWith({ chunk_size: '100' })
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// ==================== Data Transformation Tests ====================
|
||||
// Test real hooks transform data correctly
|
||||
describe('Data Transformation', () => {
|
||||
it('should transform text-input variable to string initial value', () => {
|
||||
// Arrange
|
||||
const variables: RAGPipelineVariable[] = [
|
||||
createMockVariable({ variable: 'name', label: 'Name', type: PipelineInputVarType.textInput, default_value: 'default' }),
|
||||
]
|
||||
mockParamsConfig.mockReturnValue({ variables })
|
||||
const props = createDefaultProps()
|
||||
|
||||
// Act
|
||||
renderWithProviders(<ProcessDocuments {...props} />)
|
||||
|
||||
// Assert
|
||||
const input = screen.getByTestId('input-name') as HTMLInputElement
|
||||
expect(input.defaultValue).toBe('default')
|
||||
})
|
||||
|
||||
it('should transform number variable to number initial value', () => {
|
||||
// Arrange
|
||||
const variables: RAGPipelineVariable[] = [
|
||||
createMockVariable({ variable: 'count', label: 'Count', type: PipelineInputVarType.number, default_value: '42' }),
|
||||
]
|
||||
mockParamsConfig.mockReturnValue({ variables })
|
||||
const props = createDefaultProps()
|
||||
|
||||
// Act
|
||||
renderWithProviders(<ProcessDocuments {...props} />)
|
||||
|
||||
// Assert
|
||||
const input = screen.getByTestId('input-count') as HTMLInputElement
|
||||
expect(input.defaultValue).toBe('42')
|
||||
})
|
||||
|
||||
it('should use empty string for text-input without default value', () => {
|
||||
// Arrange
|
||||
const variables: RAGPipelineVariable[] = [
|
||||
createMockVariable({ variable: 'name', label: 'Name', type: PipelineInputVarType.textInput }),
|
||||
]
|
||||
mockParamsConfig.mockReturnValue({ variables })
|
||||
const props = createDefaultProps()
|
||||
|
||||
// Act
|
||||
renderWithProviders(<ProcessDocuments {...props} />)
|
||||
|
||||
// Assert
|
||||
const input = screen.getByTestId('input-name') as HTMLInputElement
|
||||
expect(input.defaultValue).toBe('')
|
||||
})
|
||||
|
||||
it('should prioritize lastRunInputData over default_value', () => {
|
||||
// Arrange
|
||||
const variables: RAGPipelineVariable[] = [
|
||||
createMockVariable({ variable: 'size', label: 'Size', type: PipelineInputVarType.number, default_value: '100' }),
|
||||
]
|
||||
mockParamsConfig.mockReturnValue({ variables })
|
||||
const props = createDefaultProps({ lastRunInputData: { size: 999 } })
|
||||
|
||||
// Act
|
||||
renderWithProviders(<ProcessDocuments {...props} />)
|
||||
|
||||
// Assert
|
||||
const input = screen.getByTestId('input-size') as HTMLInputElement
|
||||
expect(input.defaultValue).toBe('999')
|
||||
})
|
||||
})
|
||||
|
||||
// ==================== Edge Cases ====================
|
||||
// Test boundary conditions and error handling
|
||||
describe('Edge Cases', () => {
|
||||
describe('Empty/Null data handling', () => {
|
||||
it('should handle undefined paramsConfig.variables', () => {
|
||||
// Arrange
|
||||
mockParamsConfig.mockReturnValue({ variables: undefined })
|
||||
const props = createDefaultProps()
|
||||
|
||||
// Act
|
||||
renderWithProviders(<ProcessDocuments {...props} />)
|
||||
|
||||
// Assert - should render without fields
|
||||
expect(screen.getByTestId('process-form')).toBeInTheDocument()
|
||||
expect(screen.queryByTestId(/^field-/)).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle null paramsConfig', () => {
|
||||
// Arrange
|
||||
mockParamsConfig.mockReturnValue(null)
|
||||
const props = createDefaultProps()
|
||||
|
||||
// Act
|
||||
renderWithProviders(<ProcessDocuments {...props} />)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByTestId('process-form')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle empty variables array', () => {
|
||||
// Arrange
|
||||
mockParamsConfig.mockReturnValue({ variables: [] })
|
||||
const props = createDefaultProps()
|
||||
|
||||
// Act
|
||||
renderWithProviders(<ProcessDocuments {...props} />)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByTestId('process-form')).toBeInTheDocument()
|
||||
expect(screen.queryByTestId(/^field-/)).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Multiple variables', () => {
|
||||
it('should handle multiple variables of different types', () => {
|
||||
// Arrange
|
||||
const variables: RAGPipelineVariable[] = [
|
||||
createMockVariable({ variable: 'text_field', label: 'Text', type: PipelineInputVarType.textInput, default_value: 'hello' }),
|
||||
createMockVariable({ variable: 'number_field', label: 'Number', type: PipelineInputVarType.number, default_value: '123' }),
|
||||
createMockVariable({ variable: 'select_field', label: 'Select', type: PipelineInputVarType.select, default_value: 'option1' }),
|
||||
]
|
||||
mockParamsConfig.mockReturnValue({ variables })
|
||||
const props = createDefaultProps()
|
||||
|
||||
// Act
|
||||
renderWithProviders(<ProcessDocuments {...props} />)
|
||||
|
||||
// Assert - all fields should be rendered
|
||||
expect(screen.getByTestId('field-text_field')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('field-number_field')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('field-select_field')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should submit all variables data correctly', () => {
|
||||
// Arrange
|
||||
const variables: RAGPipelineVariable[] = [
|
||||
createMockVariable({ variable: 'field1', label: 'Field 1', type: PipelineInputVarType.textInput, default_value: 'value1' }),
|
||||
createMockVariable({ variable: 'field2', label: 'Field 2', type: PipelineInputVarType.number, default_value: '42' }),
|
||||
]
|
||||
mockParamsConfig.mockReturnValue({ variables })
|
||||
const onSubmit = jest.fn()
|
||||
const props = createDefaultProps({ onSubmit })
|
||||
|
||||
// Act
|
||||
renderWithProviders(<ProcessDocuments {...props} />)
|
||||
fireEvent.submit(screen.getByTestId('process-form'))
|
||||
|
||||
// Assert - default_value is string type, so values remain as strings
|
||||
expect(onSubmit).toHaveBeenCalledWith({
|
||||
field1: 'value1',
|
||||
field2: '42',
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('Variable with options (select type)', () => {
|
||||
it('should handle select variable with options', () => {
|
||||
// Arrange
|
||||
const variables: RAGPipelineVariable[] = [
|
||||
createMockVariable({
|
||||
variable: 'mode',
|
||||
label: 'Mode',
|
||||
type: PipelineInputVarType.select,
|
||||
options: ['auto', 'manual', 'custom'],
|
||||
default_value: 'auto',
|
||||
}),
|
||||
]
|
||||
mockParamsConfig.mockReturnValue({ variables })
|
||||
const props = createDefaultProps()
|
||||
|
||||
// Act
|
||||
renderWithProviders(<ProcessDocuments {...props} />)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByTestId('field-mode')).toBeInTheDocument()
|
||||
const input = screen.getByTestId('input-mode') as HTMLInputElement
|
||||
expect(input.defaultValue).toBe('auto')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// ==================== Integration Tests ====================
|
||||
// Test Form and Actions components work together with real hooks
|
||||
describe('Integration', () => {
|
||||
it('should coordinate form submission flow correctly', () => {
|
||||
// Arrange
|
||||
const variables: RAGPipelineVariable[] = [
|
||||
createMockVariable({ variable: 'setting', label: 'Setting', type: PipelineInputVarType.textInput, default_value: 'initial' }),
|
||||
]
|
||||
mockParamsConfig.mockReturnValue({ variables })
|
||||
const onProcess = jest.fn()
|
||||
const onSubmit = jest.fn()
|
||||
const props = createDefaultProps({ onProcess, onSubmit })
|
||||
|
||||
// Act
|
||||
renderWithProviders(<ProcessDocuments {...props} />)
|
||||
|
||||
// Assert - form is rendered with correct initial data
|
||||
const input = screen.getByTestId('input-setting') as HTMLInputElement
|
||||
expect(input.defaultValue).toBe('initial')
|
||||
|
||||
// Act - click process button
|
||||
fireEvent.click(screen.getByRole('button', { name: 'datasetPipeline.operations.saveAndProcess' }))
|
||||
|
||||
// Assert - onProcess is called
|
||||
expect(onProcess).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should render complete UI with all interactive elements', () => {
|
||||
// Arrange
|
||||
const variables: RAGPipelineVariable[] = [
|
||||
createMockVariable({ variable: 'test', label: 'Test Field', type: PipelineInputVarType.textInput }),
|
||||
]
|
||||
mockParamsConfig.mockReturnValue({ variables })
|
||||
const props = createDefaultProps()
|
||||
|
||||
// Act
|
||||
renderWithProviders(<ProcessDocuments {...props} />)
|
||||
|
||||
// Assert - all UI elements are present
|
||||
expect(screen.getByTestId('process-form')).toBeInTheDocument()
|
||||
expect(screen.getByText('Test Field')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('preview-btn')).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'datasetPipeline.operations.saveAndProcess' })).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,968 @@
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import StatusItem from './index'
|
||||
import type { DocumentDisplayStatus } from '@/models/datasets'
|
||||
|
||||
// Mock i18n - required for translation
|
||||
jest.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string) => key,
|
||||
}),
|
||||
}))
|
||||
|
||||
// Mock ToastContext - required to verify notifications
|
||||
const mockNotify = jest.fn()
|
||||
jest.mock('use-context-selector', () => ({
|
||||
...jest.requireActual('use-context-selector'),
|
||||
useContext: () => ({ notify: mockNotify }),
|
||||
}))
|
||||
|
||||
// Mock document service hooks - required to avoid real API calls
|
||||
const mockEnableDocument = jest.fn()
|
||||
const mockDisableDocument = jest.fn()
|
||||
const mockDeleteDocument = jest.fn()
|
||||
|
||||
jest.mock('@/service/knowledge/use-document', () => ({
|
||||
useDocumentEnable: () => ({ mutateAsync: mockEnableDocument }),
|
||||
useDocumentDisable: () => ({ mutateAsync: mockDisableDocument }),
|
||||
useDocumentDelete: () => ({ mutateAsync: mockDeleteDocument }),
|
||||
}))
|
||||
|
||||
// Mock useDebounceFn to execute immediately for testing
|
||||
jest.mock('ahooks', () => ({
|
||||
...jest.requireActual('ahooks'),
|
||||
useDebounceFn: (fn: (...args: unknown[]) => void) => ({ run: fn }),
|
||||
}))
|
||||
|
||||
// Test utilities
|
||||
const createQueryClient = () =>
|
||||
new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: { retry: false },
|
||||
mutations: { retry: false },
|
||||
},
|
||||
})
|
||||
|
||||
const renderWithProviders = (ui: React.ReactElement) => {
|
||||
const queryClient = createQueryClient()
|
||||
return render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
{ui}
|
||||
</QueryClientProvider>,
|
||||
)
|
||||
}
|
||||
|
||||
// Factory functions for test data
|
||||
const createDetailProps = (overrides: Partial<{
|
||||
enabled: boolean
|
||||
archived: boolean
|
||||
id: string
|
||||
}> = {}) => ({
|
||||
enabled: false,
|
||||
archived: false,
|
||||
id: 'doc-123',
|
||||
...overrides,
|
||||
})
|
||||
|
||||
describe('StatusItem', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks()
|
||||
mockEnableDocument.mockResolvedValue({ result: 'success' })
|
||||
mockDisableDocument.mockResolvedValue({ result: 'success' })
|
||||
mockDeleteDocument.mockResolvedValue({ result: 'success' })
|
||||
})
|
||||
|
||||
// ==================== Rendering Tests ====================
|
||||
// Test basic rendering with different status values
|
||||
describe('Rendering', () => {
|
||||
it('should render without crashing', () => {
|
||||
// Arrange & Act
|
||||
renderWithProviders(<StatusItem status="available" />)
|
||||
|
||||
// Assert - check indicator element exists (real Indicator component)
|
||||
const indicator = screen.getByTestId('status-indicator')
|
||||
expect(indicator).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it.each([
|
||||
['queuing', 'bg-components-badge-status-light-warning-bg'],
|
||||
['indexing', 'bg-components-badge-status-light-normal-bg'],
|
||||
['paused', 'bg-components-badge-status-light-warning-bg'],
|
||||
['error', 'bg-components-badge-status-light-error-bg'],
|
||||
['available', 'bg-components-badge-status-light-success-bg'],
|
||||
['enabled', 'bg-components-badge-status-light-success-bg'],
|
||||
['disabled', 'bg-components-badge-status-light-disabled-bg'],
|
||||
['archived', 'bg-components-badge-status-light-disabled-bg'],
|
||||
] as const)('should render status "%s" with correct indicator background', (status, expectedBg) => {
|
||||
// Arrange & Act
|
||||
renderWithProviders(<StatusItem status={status} />)
|
||||
|
||||
// Assert
|
||||
const indicator = screen.getByTestId('status-indicator')
|
||||
expect(indicator).toHaveClass(expectedBg)
|
||||
})
|
||||
|
||||
it('should render status text from translation', () => {
|
||||
// Arrange & Act
|
||||
renderWithProviders(<StatusItem status="available" />)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByText('datasetDocuments.list.status.available')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle case-insensitive status', () => {
|
||||
// Arrange & Act
|
||||
renderWithProviders(
|
||||
<StatusItem status={'AVAILABLE' as DocumentDisplayStatus} />,
|
||||
)
|
||||
|
||||
// Assert
|
||||
const indicator = screen.getByTestId('status-indicator')
|
||||
expect(indicator).toHaveClass('bg-components-badge-status-light-success-bg')
|
||||
})
|
||||
})
|
||||
|
||||
// ==================== Props Testing ====================
|
||||
// Test all prop variations and combinations
|
||||
describe('Props', () => {
|
||||
// reverse prop tests
|
||||
describe('reverse prop', () => {
|
||||
it('should apply default layout when reverse is false', () => {
|
||||
// Arrange & Act
|
||||
const { container } = renderWithProviders(<StatusItem status="available" reverse={false} />)
|
||||
|
||||
// Assert
|
||||
const wrapper = container.firstChild as HTMLElement
|
||||
expect(wrapper).not.toHaveClass('flex-row-reverse')
|
||||
})
|
||||
|
||||
it('should apply reversed layout when reverse is true', () => {
|
||||
// Arrange & Act
|
||||
const { container } = renderWithProviders(<StatusItem status="available" reverse />)
|
||||
|
||||
// Assert
|
||||
const wrapper = container.firstChild as HTMLElement
|
||||
expect(wrapper).toHaveClass('flex-row-reverse')
|
||||
})
|
||||
|
||||
it('should apply ml-2 to indicator when reversed', () => {
|
||||
// Arrange & Act
|
||||
renderWithProviders(<StatusItem status="available" reverse />)
|
||||
|
||||
// Assert
|
||||
const indicator = screen.getByTestId('status-indicator')
|
||||
expect(indicator).toHaveClass('ml-2')
|
||||
})
|
||||
|
||||
it('should apply mr-2 to indicator when not reversed', () => {
|
||||
// Arrange & Act
|
||||
renderWithProviders(<StatusItem status="available" reverse={false} />)
|
||||
|
||||
// Assert
|
||||
const indicator = screen.getByTestId('status-indicator')
|
||||
expect(indicator).toHaveClass('mr-2')
|
||||
})
|
||||
})
|
||||
|
||||
// scene prop tests
|
||||
describe('scene prop', () => {
|
||||
it('should not render switch in list scene', () => {
|
||||
// Arrange & Act
|
||||
renderWithProviders(
|
||||
<StatusItem
|
||||
status="available"
|
||||
scene="list"
|
||||
detail={createDetailProps()}
|
||||
/>,
|
||||
)
|
||||
|
||||
// Assert - Switch renders as a button element
|
||||
expect(screen.queryByRole('switch')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render switch in detail scene', () => {
|
||||
// Arrange & Act
|
||||
renderWithProviders(
|
||||
<StatusItem
|
||||
status="available"
|
||||
scene="detail"
|
||||
detail={createDetailProps()}
|
||||
/>,
|
||||
)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByRole('switch')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should default to list scene', () => {
|
||||
// Arrange & Act
|
||||
renderWithProviders(
|
||||
<StatusItem
|
||||
status="available"
|
||||
detail={createDetailProps()}
|
||||
/>,
|
||||
)
|
||||
|
||||
// Assert
|
||||
expect(screen.queryByRole('switch')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// textCls prop tests
|
||||
describe('textCls prop', () => {
|
||||
it('should apply custom text class', () => {
|
||||
// Arrange & Act
|
||||
renderWithProviders(
|
||||
<StatusItem status="available" textCls="custom-text-class" />,
|
||||
)
|
||||
|
||||
// Assert
|
||||
const statusText = screen.getByText('datasetDocuments.list.status.available')
|
||||
expect(statusText).toHaveClass('custom-text-class')
|
||||
})
|
||||
|
||||
it('should default to empty string', () => {
|
||||
// Arrange & Act
|
||||
renderWithProviders(<StatusItem status="available" />)
|
||||
|
||||
// Assert
|
||||
const statusText = screen.getByText('datasetDocuments.list.status.available')
|
||||
expect(statusText).toHaveClass('text-sm')
|
||||
})
|
||||
})
|
||||
|
||||
// errorMessage prop tests
|
||||
describe('errorMessage prop', () => {
|
||||
it('should render tooltip trigger when errorMessage is provided', () => {
|
||||
// Arrange & Act
|
||||
renderWithProviders(
|
||||
<StatusItem status="error" errorMessage="Something went wrong" />,
|
||||
)
|
||||
|
||||
// Assert - tooltip trigger element should exist
|
||||
const tooltipTrigger = screen.getByTestId('error-tooltip-trigger')
|
||||
expect(tooltipTrigger).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show error message on hover', async () => {
|
||||
// Arrange
|
||||
renderWithProviders(
|
||||
<StatusItem status="error" errorMessage="Something went wrong" />,
|
||||
)
|
||||
|
||||
// Act - hover the tooltip trigger
|
||||
const tooltipTrigger = screen.getByTestId('error-tooltip-trigger')
|
||||
fireEvent.mouseEnter(tooltipTrigger)
|
||||
|
||||
// Assert - wait for tooltip content to appear
|
||||
expect(await screen.findByText('Something went wrong')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not render tooltip trigger when errorMessage is not provided', () => {
|
||||
// Arrange & Act
|
||||
renderWithProviders(<StatusItem status="error" />)
|
||||
|
||||
// Assert - tooltip trigger should not exist
|
||||
const tooltipTrigger = screen.queryByTestId('error-tooltip-trigger')
|
||||
expect(tooltipTrigger).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not render tooltip trigger when errorMessage is empty', () => {
|
||||
// Arrange & Act
|
||||
renderWithProviders(<StatusItem status="error" errorMessage="" />)
|
||||
|
||||
// Assert - tooltip trigger should not exist
|
||||
const tooltipTrigger = screen.queryByTestId('error-tooltip-trigger')
|
||||
expect(tooltipTrigger).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// detail prop tests
|
||||
describe('detail prop', () => {
|
||||
it('should use default values when detail is undefined', () => {
|
||||
// Arrange & Act
|
||||
renderWithProviders(
|
||||
<StatusItem status="available" scene="detail" />,
|
||||
)
|
||||
|
||||
// Assert - switch should be unchecked (defaultValue = false when archived = false and enabled = false)
|
||||
const switchEl = screen.getByRole('switch')
|
||||
expect(switchEl).toHaveAttribute('aria-checked', 'false')
|
||||
})
|
||||
|
||||
it('should use enabled value from detail', () => {
|
||||
// Arrange & Act
|
||||
renderWithProviders(
|
||||
<StatusItem
|
||||
status="available"
|
||||
scene="detail"
|
||||
detail={createDetailProps({ enabled: true })}
|
||||
/>,
|
||||
)
|
||||
|
||||
// Assert
|
||||
const switchEl = screen.getByRole('switch')
|
||||
expect(switchEl).toHaveAttribute('aria-checked', 'true')
|
||||
})
|
||||
|
||||
it('should set switch to false when archived regardless of enabled', () => {
|
||||
// Arrange & Act
|
||||
renderWithProviders(
|
||||
<StatusItem
|
||||
status="available"
|
||||
scene="detail"
|
||||
detail={createDetailProps({ enabled: true, archived: true })}
|
||||
/>,
|
||||
)
|
||||
|
||||
// Assert - archived overrides enabled, defaultValue becomes false
|
||||
const switchEl = screen.getByRole('switch')
|
||||
expect(switchEl).toHaveAttribute('aria-checked', 'false')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// ==================== Memoization Tests ====================
|
||||
// Test useMemo logic for embedding status (disables switch)
|
||||
describe('Memoization', () => {
|
||||
it.each([
|
||||
['queuing', true],
|
||||
['indexing', true],
|
||||
['paused', true],
|
||||
['available', false],
|
||||
['enabled', false],
|
||||
['disabled', false],
|
||||
['archived', false],
|
||||
['error', false],
|
||||
] as const)('should correctly identify embedding status for "%s" - disabled: %s', (status, isEmbedding) => {
|
||||
// Arrange & Act
|
||||
renderWithProviders(
|
||||
<StatusItem
|
||||
status={status}
|
||||
scene="detail"
|
||||
detail={createDetailProps()}
|
||||
/>,
|
||||
)
|
||||
|
||||
// Assert - check if switch is visually disabled (via CSS classes)
|
||||
// The Switch component uses CSS classes for disabled state, not the native disabled attribute
|
||||
const switchEl = screen.getByRole('switch')
|
||||
if (isEmbedding)
|
||||
expect(switchEl).toHaveClass('!cursor-not-allowed', '!opacity-50')
|
||||
else
|
||||
expect(switchEl).not.toHaveClass('!cursor-not-allowed')
|
||||
})
|
||||
|
||||
it('should disable switch when archived', () => {
|
||||
// Arrange & Act
|
||||
renderWithProviders(
|
||||
<StatusItem
|
||||
status="available"
|
||||
scene="detail"
|
||||
detail={createDetailProps({ archived: true })}
|
||||
/>,
|
||||
)
|
||||
|
||||
// Assert - visually disabled via CSS classes
|
||||
const switchEl = screen.getByRole('switch')
|
||||
expect(switchEl).toHaveClass('!cursor-not-allowed', '!opacity-50')
|
||||
})
|
||||
|
||||
it('should disable switch when both embedding and archived', () => {
|
||||
// Arrange & Act
|
||||
renderWithProviders(
|
||||
<StatusItem
|
||||
status="indexing"
|
||||
scene="detail"
|
||||
detail={createDetailProps({ archived: true })}
|
||||
/>,
|
||||
)
|
||||
|
||||
// Assert - visually disabled via CSS classes
|
||||
const switchEl = screen.getByRole('switch')
|
||||
expect(switchEl).toHaveClass('!cursor-not-allowed', '!opacity-50')
|
||||
})
|
||||
})
|
||||
|
||||
// ==================== Switch Toggle Tests ====================
|
||||
// Test Switch toggle interactions
|
||||
describe('Switch Toggle', () => {
|
||||
it('should call enable operation when switch is toggled on', async () => {
|
||||
// Arrange
|
||||
const mockOnUpdate = jest.fn()
|
||||
renderWithProviders(
|
||||
<StatusItem
|
||||
status="disabled"
|
||||
scene="detail"
|
||||
detail={createDetailProps({ enabled: false })}
|
||||
datasetId="dataset-123"
|
||||
onUpdate={mockOnUpdate}
|
||||
/>,
|
||||
)
|
||||
|
||||
// Act
|
||||
const switchEl = screen.getByRole('switch')
|
||||
fireEvent.click(switchEl)
|
||||
|
||||
// Assert
|
||||
await waitFor(() => {
|
||||
expect(mockEnableDocument).toHaveBeenCalledWith({
|
||||
datasetId: 'dataset-123',
|
||||
documentId: 'doc-123',
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it('should call disable operation when switch is toggled off', async () => {
|
||||
// Arrange
|
||||
const mockOnUpdate = jest.fn()
|
||||
renderWithProviders(
|
||||
<StatusItem
|
||||
status="enabled"
|
||||
scene="detail"
|
||||
detail={createDetailProps({ enabled: true })}
|
||||
datasetId="dataset-123"
|
||||
onUpdate={mockOnUpdate}
|
||||
/>,
|
||||
)
|
||||
|
||||
// Act
|
||||
const switchEl = screen.getByRole('switch')
|
||||
fireEvent.click(switchEl)
|
||||
|
||||
// Assert
|
||||
await waitFor(() => {
|
||||
expect(mockDisableDocument).toHaveBeenCalledWith({
|
||||
datasetId: 'dataset-123',
|
||||
documentId: 'doc-123',
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it('should not call any operation when archived', () => {
|
||||
// Arrange
|
||||
renderWithProviders(
|
||||
<StatusItem
|
||||
status="available"
|
||||
scene="detail"
|
||||
detail={createDetailProps({ archived: true })}
|
||||
datasetId="dataset-123"
|
||||
/>,
|
||||
)
|
||||
|
||||
// Act
|
||||
const switchEl = screen.getByRole('switch')
|
||||
fireEvent.click(switchEl)
|
||||
|
||||
// Assert
|
||||
expect(mockEnableDocument).not.toHaveBeenCalled()
|
||||
expect(mockDisableDocument).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should render switch as checked when enabled is true', () => {
|
||||
// Arrange & Act
|
||||
renderWithProviders(
|
||||
<StatusItem
|
||||
status="enabled"
|
||||
scene="detail"
|
||||
detail={createDetailProps({ enabled: true })}
|
||||
datasetId="dataset-123"
|
||||
/>,
|
||||
)
|
||||
|
||||
// Assert - verify switch shows checked state
|
||||
const switchEl = screen.getByRole('switch')
|
||||
expect(switchEl).toHaveAttribute('aria-checked', 'true')
|
||||
})
|
||||
|
||||
it('should render switch as unchecked when enabled is false', () => {
|
||||
// Arrange & Act
|
||||
renderWithProviders(
|
||||
<StatusItem
|
||||
status="disabled"
|
||||
scene="detail"
|
||||
detail={createDetailProps({ enabled: false })}
|
||||
datasetId="dataset-123"
|
||||
/>,
|
||||
)
|
||||
|
||||
// Assert - verify switch shows unchecked state
|
||||
const switchEl = screen.getByRole('switch')
|
||||
expect(switchEl).toHaveAttribute('aria-checked', 'false')
|
||||
})
|
||||
|
||||
it('should skip enable operation when props.enabled is true (guard branch)', () => {
|
||||
// Covers guard condition: if (operationName === 'enable' && enabled) return
|
||||
// Note: The guard checks props.enabled, NOT the Switch's internal UI state.
|
||||
// This prevents redundant API calls when the UI toggles back to a state
|
||||
// that already matches the server-side data (props haven't been updated yet).
|
||||
const mockOnUpdate = jest.fn()
|
||||
renderWithProviders(
|
||||
<StatusItem
|
||||
status="enabled"
|
||||
scene="detail"
|
||||
detail={createDetailProps({ enabled: true })}
|
||||
datasetId="dataset-123"
|
||||
onUpdate={mockOnUpdate}
|
||||
/>,
|
||||
)
|
||||
|
||||
const switchEl = screen.getByRole('switch')
|
||||
// First click: Switch UI toggles OFF, calls disable (props.enabled=true, so allowed)
|
||||
fireEvent.click(switchEl)
|
||||
// Second click: Switch UI toggles ON, tries to call enable
|
||||
// BUT props.enabled is still true (not updated), so guard skips the API call
|
||||
fireEvent.click(switchEl)
|
||||
|
||||
// Assert - disable was called once, enable was skipped because props.enabled=true
|
||||
expect(mockDisableDocument).toHaveBeenCalledTimes(1)
|
||||
expect(mockEnableDocument).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should skip disable operation when props.enabled is false (guard branch)', () => {
|
||||
// Covers guard condition: if (operationName === 'disable' && !enabled) return
|
||||
// Note: The guard checks props.enabled, NOT the Switch's internal UI state.
|
||||
// This prevents redundant API calls when the UI toggles back to a state
|
||||
// that already matches the server-side data (props haven't been updated yet).
|
||||
const mockOnUpdate = jest.fn()
|
||||
renderWithProviders(
|
||||
<StatusItem
|
||||
status="disabled"
|
||||
scene="detail"
|
||||
detail={createDetailProps({ enabled: false })}
|
||||
datasetId="dataset-123"
|
||||
onUpdate={mockOnUpdate}
|
||||
/>,
|
||||
)
|
||||
|
||||
const switchEl = screen.getByRole('switch')
|
||||
// First click: Switch UI toggles ON, calls enable (props.enabled=false, so allowed)
|
||||
fireEvent.click(switchEl)
|
||||
// Second click: Switch UI toggles OFF, tries to call disable
|
||||
// BUT props.enabled is still false (not updated), so guard skips the API call
|
||||
fireEvent.click(switchEl)
|
||||
|
||||
// Assert - enable was called once, disable was skipped because props.enabled=false
|
||||
expect(mockEnableDocument).toHaveBeenCalledTimes(1)
|
||||
expect(mockDisableDocument).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
// ==================== onUpdate Callback Tests ====================
|
||||
// Test onUpdate callback behavior
|
||||
describe('onUpdate Callback', () => {
|
||||
it('should call onUpdate with operation name on successful enable', async () => {
|
||||
// Arrange
|
||||
const mockOnUpdate = jest.fn()
|
||||
renderWithProviders(
|
||||
<StatusItem
|
||||
status="disabled"
|
||||
scene="detail"
|
||||
detail={createDetailProps({ enabled: false })}
|
||||
datasetId="dataset-123"
|
||||
onUpdate={mockOnUpdate}
|
||||
/>,
|
||||
)
|
||||
|
||||
// Act
|
||||
const switchEl = screen.getByRole('switch')
|
||||
fireEvent.click(switchEl)
|
||||
|
||||
// Assert
|
||||
await waitFor(() => {
|
||||
expect(mockOnUpdate).toHaveBeenCalledWith('enable')
|
||||
})
|
||||
})
|
||||
|
||||
it('should call onUpdate with operation name on successful disable', async () => {
|
||||
// Arrange
|
||||
const mockOnUpdate = jest.fn()
|
||||
renderWithProviders(
|
||||
<StatusItem
|
||||
status="enabled"
|
||||
scene="detail"
|
||||
detail={createDetailProps({ enabled: true })}
|
||||
datasetId="dataset-123"
|
||||
onUpdate={mockOnUpdate}
|
||||
/>,
|
||||
)
|
||||
|
||||
// Act
|
||||
const switchEl = screen.getByRole('switch')
|
||||
fireEvent.click(switchEl)
|
||||
|
||||
// Assert
|
||||
await waitFor(() => {
|
||||
expect(mockOnUpdate).toHaveBeenCalledWith('disable')
|
||||
})
|
||||
})
|
||||
|
||||
it('should not call onUpdate when operation fails', async () => {
|
||||
// Arrange
|
||||
mockEnableDocument.mockRejectedValue(new Error('API Error'))
|
||||
const mockOnUpdate = jest.fn()
|
||||
renderWithProviders(
|
||||
<StatusItem
|
||||
status="disabled"
|
||||
scene="detail"
|
||||
detail={createDetailProps({ enabled: false })}
|
||||
datasetId="dataset-123"
|
||||
onUpdate={mockOnUpdate}
|
||||
/>,
|
||||
)
|
||||
|
||||
// Act
|
||||
const switchEl = screen.getByRole('switch')
|
||||
fireEvent.click(switchEl)
|
||||
|
||||
// Assert
|
||||
await waitFor(() => {
|
||||
expect(mockNotify).toHaveBeenCalledWith({
|
||||
type: 'error',
|
||||
message: 'common.actionMsg.modifiedUnsuccessfully',
|
||||
})
|
||||
})
|
||||
expect(mockOnUpdate).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should not throw when onUpdate is not provided', () => {
|
||||
// Arrange
|
||||
renderWithProviders(
|
||||
<StatusItem
|
||||
status="disabled"
|
||||
scene="detail"
|
||||
detail={createDetailProps({ enabled: false })}
|
||||
datasetId="dataset-123"
|
||||
/>,
|
||||
)
|
||||
|
||||
// Act
|
||||
const switchEl = screen.getByRole('switch')
|
||||
|
||||
// Assert - should not throw
|
||||
expect(() => fireEvent.click(switchEl)).not.toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
// ==================== API Calls ====================
|
||||
// Test API operations and toast notifications
|
||||
describe('API Operations', () => {
|
||||
it('should show success toast on successful operation', async () => {
|
||||
// Arrange
|
||||
renderWithProviders(
|
||||
<StatusItem
|
||||
status="disabled"
|
||||
scene="detail"
|
||||
detail={createDetailProps({ enabled: false })}
|
||||
datasetId="dataset-123"
|
||||
/>,
|
||||
)
|
||||
|
||||
// Act
|
||||
const switchEl = screen.getByRole('switch')
|
||||
fireEvent.click(switchEl)
|
||||
|
||||
// Assert
|
||||
await waitFor(() => {
|
||||
expect(mockNotify).toHaveBeenCalledWith({
|
||||
type: 'success',
|
||||
message: 'common.actionMsg.modifiedSuccessfully',
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it('should show error toast on failed operation', async () => {
|
||||
// Arrange
|
||||
mockDisableDocument.mockRejectedValue(new Error('Network error'))
|
||||
renderWithProviders(
|
||||
<StatusItem
|
||||
status="enabled"
|
||||
scene="detail"
|
||||
detail={createDetailProps({ enabled: true })}
|
||||
datasetId="dataset-123"
|
||||
/>,
|
||||
)
|
||||
|
||||
// Act
|
||||
const switchEl = screen.getByRole('switch')
|
||||
fireEvent.click(switchEl)
|
||||
|
||||
// Assert
|
||||
await waitFor(() => {
|
||||
expect(mockNotify).toHaveBeenCalledWith({
|
||||
type: 'error',
|
||||
message: 'common.actionMsg.modifiedUnsuccessfully',
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it('should pass correct parameters to enable API', async () => {
|
||||
// Arrange
|
||||
renderWithProviders(
|
||||
<StatusItem
|
||||
status="disabled"
|
||||
scene="detail"
|
||||
detail={createDetailProps({ enabled: false, id: 'test-doc-id' })}
|
||||
datasetId="test-dataset-id"
|
||||
/>,
|
||||
)
|
||||
|
||||
// Act
|
||||
const switchEl = screen.getByRole('switch')
|
||||
fireEvent.click(switchEl)
|
||||
|
||||
// Assert
|
||||
await waitFor(() => {
|
||||
expect(mockEnableDocument).toHaveBeenCalledWith({
|
||||
datasetId: 'test-dataset-id',
|
||||
documentId: 'test-doc-id',
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it('should pass correct parameters to disable API', async () => {
|
||||
// Arrange
|
||||
renderWithProviders(
|
||||
<StatusItem
|
||||
status="enabled"
|
||||
scene="detail"
|
||||
detail={createDetailProps({ enabled: true, id: 'test-doc-456' })}
|
||||
datasetId="test-dataset-456"
|
||||
/>,
|
||||
)
|
||||
|
||||
// Act
|
||||
const switchEl = screen.getByRole('switch')
|
||||
fireEvent.click(switchEl)
|
||||
|
||||
// Assert
|
||||
await waitFor(() => {
|
||||
expect(mockDisableDocument).toHaveBeenCalledWith({
|
||||
datasetId: 'test-dataset-456',
|
||||
documentId: 'test-doc-456',
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// ==================== Edge Cases ====================
|
||||
// Test boundary conditions and unusual inputs
|
||||
describe('Edge Cases', () => {
|
||||
it('should handle empty datasetId', () => {
|
||||
// Arrange & Act
|
||||
renderWithProviders(
|
||||
<StatusItem
|
||||
status="available"
|
||||
scene="detail"
|
||||
detail={createDetailProps()}
|
||||
/>,
|
||||
)
|
||||
|
||||
// Assert - should render without errors
|
||||
expect(screen.getByRole('switch')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle undefined detail gracefully', () => {
|
||||
// Arrange & Act
|
||||
renderWithProviders(
|
||||
<StatusItem
|
||||
status="available"
|
||||
scene="detail"
|
||||
detail={undefined}
|
||||
/>,
|
||||
)
|
||||
|
||||
// Assert
|
||||
const switchEl = screen.getByRole('switch')
|
||||
expect(switchEl).toHaveAttribute('aria-checked', 'false')
|
||||
})
|
||||
|
||||
it('should handle empty string id in detail', async () => {
|
||||
// Arrange
|
||||
renderWithProviders(
|
||||
<StatusItem
|
||||
status="disabled"
|
||||
scene="detail"
|
||||
detail={createDetailProps({ enabled: false, id: '' })}
|
||||
datasetId="dataset-123"
|
||||
/>,
|
||||
)
|
||||
|
||||
// Act
|
||||
const switchEl = screen.getByRole('switch')
|
||||
fireEvent.click(switchEl)
|
||||
|
||||
// Assert
|
||||
await waitFor(() => {
|
||||
expect(mockEnableDocument).toHaveBeenCalledWith({
|
||||
datasetId: 'dataset-123',
|
||||
documentId: '',
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it('should handle very long error messages', async () => {
|
||||
// Arrange
|
||||
const longErrorMessage = 'A'.repeat(500)
|
||||
renderWithProviders(
|
||||
<StatusItem status="error" errorMessage={longErrorMessage} />,
|
||||
)
|
||||
|
||||
// Act - hover to show tooltip
|
||||
const tooltipTrigger = screen.getByTestId('error-tooltip-trigger')
|
||||
fireEvent.mouseEnter(tooltipTrigger)
|
||||
|
||||
// Assert
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(longErrorMessage)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('should handle special characters in error message', async () => {
|
||||
// Arrange
|
||||
const specialChars = '<script>alert("xss")</script> & < > " \''
|
||||
renderWithProviders(
|
||||
<StatusItem status="error" errorMessage={specialChars} />,
|
||||
)
|
||||
|
||||
// Act - hover to show tooltip
|
||||
const tooltipTrigger = screen.getByTestId('error-tooltip-trigger')
|
||||
fireEvent.mouseEnter(tooltipTrigger)
|
||||
|
||||
// Assert
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(specialChars)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('should handle all status types in sequence', () => {
|
||||
// Arrange
|
||||
const statuses: DocumentDisplayStatus[] = [
|
||||
'queuing', 'indexing', 'paused', 'error',
|
||||
'available', 'enabled', 'disabled', 'archived',
|
||||
]
|
||||
|
||||
// Act & Assert
|
||||
statuses.forEach((status) => {
|
||||
const { unmount } = renderWithProviders(<StatusItem status={status} />)
|
||||
const indicator = screen.getByTestId('status-indicator')
|
||||
expect(indicator).toBeInTheDocument()
|
||||
unmount()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// ==================== Component Memoization ====================
|
||||
// Test React.memo behavior
|
||||
describe('Component Memoization', () => {
|
||||
it('should be wrapped with React.memo', () => {
|
||||
// Assert
|
||||
expect(StatusItem).toHaveProperty('$$typeof', Symbol.for('react.memo'))
|
||||
})
|
||||
|
||||
it('should render correctly with same props', () => {
|
||||
// Arrange
|
||||
const props = {
|
||||
status: 'available' as const,
|
||||
scene: 'detail' as const,
|
||||
detail: createDetailProps(),
|
||||
}
|
||||
|
||||
// Act
|
||||
const { rerender } = renderWithProviders(<StatusItem {...props} />)
|
||||
rerender(
|
||||
<QueryClientProvider client={createQueryClient()}>
|
||||
<StatusItem {...props} />
|
||||
</QueryClientProvider>,
|
||||
)
|
||||
|
||||
// Assert
|
||||
const indicator = screen.getByTestId('status-indicator')
|
||||
expect(indicator).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should update when status prop changes', () => {
|
||||
// Arrange
|
||||
const { rerender } = renderWithProviders(<StatusItem status="available" />)
|
||||
|
||||
// Assert initial - green/success background
|
||||
let indicator = screen.getByTestId('status-indicator')
|
||||
expect(indicator).toHaveClass('bg-components-badge-status-light-success-bg')
|
||||
|
||||
// Act
|
||||
rerender(
|
||||
<QueryClientProvider client={createQueryClient()}>
|
||||
<StatusItem status="error" />
|
||||
</QueryClientProvider>,
|
||||
)
|
||||
|
||||
// Assert updated - red/error background
|
||||
indicator = screen.getByTestId('status-indicator')
|
||||
expect(indicator).toHaveClass('bg-components-badge-status-light-error-bg')
|
||||
})
|
||||
})
|
||||
|
||||
// ==================== Styling Tests ====================
|
||||
// Test CSS classes and styling
|
||||
describe('Styling', () => {
|
||||
it('should apply correct status text color for green status', () => {
|
||||
// Arrange & Act
|
||||
renderWithProviders(<StatusItem status="available" />)
|
||||
|
||||
// Assert
|
||||
const statusText = screen.getByText('datasetDocuments.list.status.available')
|
||||
expect(statusText).toHaveClass('text-util-colors-green-green-600')
|
||||
})
|
||||
|
||||
it('should apply correct status text color for red status', () => {
|
||||
// Arrange & Act
|
||||
renderWithProviders(<StatusItem status="error" />)
|
||||
|
||||
// Assert
|
||||
const statusText = screen.getByText('datasetDocuments.list.status.error')
|
||||
expect(statusText).toHaveClass('text-util-colors-red-red-600')
|
||||
})
|
||||
|
||||
it('should apply correct status text color for orange status', () => {
|
||||
// Arrange & Act
|
||||
renderWithProviders(<StatusItem status="queuing" />)
|
||||
|
||||
// Assert
|
||||
const statusText = screen.getByText('datasetDocuments.list.status.queuing')
|
||||
expect(statusText).toHaveClass('text-util-colors-warning-warning-600')
|
||||
})
|
||||
|
||||
it('should apply correct status text color for blue status', () => {
|
||||
// Arrange & Act
|
||||
renderWithProviders(<StatusItem status="indexing" />)
|
||||
|
||||
// Assert
|
||||
const statusText = screen.getByText('datasetDocuments.list.status.indexing')
|
||||
expect(statusText).toHaveClass('text-util-colors-blue-light-blue-light-600')
|
||||
})
|
||||
|
||||
it('should apply correct status text color for gray status', () => {
|
||||
// Arrange & Act
|
||||
renderWithProviders(<StatusItem status="disabled" />)
|
||||
|
||||
// Assert
|
||||
const statusText = screen.getByText('datasetDocuments.list.status.disabled')
|
||||
expect(statusText).toHaveClass('text-text-tertiary')
|
||||
})
|
||||
|
||||
it('should render switch with md size in detail scene', () => {
|
||||
// Arrange & Act
|
||||
renderWithProviders(
|
||||
<StatusItem
|
||||
status="available"
|
||||
scene="detail"
|
||||
detail={createDetailProps()}
|
||||
/>,
|
||||
)
|
||||
|
||||
// Assert - check switch has the md size class (h-4 w-7)
|
||||
const switchEl = screen.getByRole('switch')
|
||||
expect(switchEl).toHaveClass('h-4', 'w-7')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -105,6 +105,7 @@ const StatusItem = ({
|
||||
<div className='max-w-[260px] break-all'>{errorMessage}</div>
|
||||
}
|
||||
triggerClassName='ml-1 w-4 h-4'
|
||||
triggerTestId='error-tooltip-trigger'
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -47,6 +47,7 @@ export default function Indicator({
|
||||
}: IndicatorProps) {
|
||||
return (
|
||||
<div
|
||||
data-testid="status-indicator"
|
||||
className={classNames(
|
||||
'h-2 w-2 rounded-[3px] border border-solid',
|
||||
BACKGROUND_MAP[color],
|
||||
|
||||
@@ -61,7 +61,8 @@ export const pluginManifestInMarketToPluginProps = (pluginManifest: PluginManife
|
||||
}
|
||||
|
||||
export const parseGitHubUrl = (url: string): GitHubUrlInfo => {
|
||||
const match = url.match(/^https:\/\/github\.com\/([^/]+)\/([^/]+)\/?$/)
|
||||
const githubUrlRegex = /^https:\/\/github\.com\/([^/]+)\/([^/]+)\/?$/
|
||||
const match = githubUrlRegex.exec(url)
|
||||
return match ? { isValid: true, owner: match[1], repo: match[2] } : { isValid: false }
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
import {
|
||||
memo,
|
||||
useState,
|
||||
} from 'react'
|
||||
import {
|
||||
RiAddLine,
|
||||
RiKey2Line,
|
||||
} from '@remixicon/react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import AddOAuthButton from './authorize/add-oauth-button'
|
||||
import AddApiKeyButton from './authorize/add-api-key-button'
|
||||
import type { PluginPayload } from './types'
|
||||
import cn from '@/utils/classnames'
|
||||
import {
|
||||
PortalToFollowElem,
|
||||
PortalToFollowElemContent,
|
||||
PortalToFollowElemTrigger,
|
||||
} from '@/app/components/base/portal-to-follow-elem'
|
||||
|
||||
export type CredentialConfigHeaderProps = {
|
||||
pluginPayload: PluginPayload
|
||||
canOAuth?: boolean
|
||||
canApiKey?: boolean
|
||||
hasOAuthClientConfigured?: boolean
|
||||
disabled?: boolean
|
||||
onCredentialAdded?: () => void
|
||||
onAddMenuOpenChange?: (open: boolean) => void
|
||||
}
|
||||
|
||||
const CredentialConfigHeader = ({
|
||||
pluginPayload,
|
||||
canOAuth,
|
||||
canApiKey,
|
||||
hasOAuthClientConfigured,
|
||||
disabled,
|
||||
onCredentialAdded,
|
||||
onAddMenuOpenChange,
|
||||
}: CredentialConfigHeaderProps) => {
|
||||
const { t } = useTranslation()
|
||||
const [showAddMenu, setShowAddMenu] = useState(false)
|
||||
|
||||
const handleAddMenuOpenChange = (open: boolean) => {
|
||||
setShowAddMenu(open)
|
||||
onAddMenuOpenChange?.(open)
|
||||
}
|
||||
|
||||
const addButtonDisabled = disabled || (!canOAuth && !canApiKey && !hasOAuthClientConfigured)
|
||||
|
||||
return (
|
||||
<div className='flex items-start justify-between gap-2'>
|
||||
<div className='flex items-start gap-2'>
|
||||
<RiKey2Line className='mt-0.5 h-4 w-4 text-text-tertiary' />
|
||||
<div className='space-y-0.5'>
|
||||
<div className='system-md-semibold text-text-primary'>
|
||||
{t('plugin.auth.configuredCredentials.title')}
|
||||
</div>
|
||||
<div className='system-xs-regular text-text-tertiary'>
|
||||
{t('plugin.auth.configuredCredentials.desc')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<PortalToFollowElem
|
||||
open={showAddMenu}
|
||||
onOpenChange={handleAddMenuOpenChange}
|
||||
placement='bottom-end'
|
||||
offset={6}
|
||||
>
|
||||
<PortalToFollowElemTrigger asChild>
|
||||
<button
|
||||
type='button'
|
||||
className={cn(
|
||||
'flex h-9 w-9 items-center justify-center rounded-full bg-primary-600 text-white hover:bg-primary-700',
|
||||
addButtonDisabled && 'pointer-events-none opacity-50',
|
||||
)}
|
||||
onClick={() => handleAddMenuOpenChange(!showAddMenu)}
|
||||
>
|
||||
<RiAddLine className='h-5 w-5' />
|
||||
</button>
|
||||
</PortalToFollowElemTrigger>
|
||||
<PortalToFollowElemContent className='z-[120]'>
|
||||
<div className='w-[220px] rounded-xl border border-components-panel-border bg-components-panel-bg shadow-lg'>
|
||||
<div className='flex flex-col gap-1 p-1'>
|
||||
{canOAuth && (
|
||||
<AddOAuthButton
|
||||
pluginPayload={pluginPayload}
|
||||
buttonVariant='ghost'
|
||||
className='w-full justify-between bg-transparent text-text-primary hover:bg-transparent'
|
||||
buttonText={t('plugin.auth.addOAuth')}
|
||||
disabled={disabled}
|
||||
onUpdate={() => {
|
||||
setShowAddMenu(false)
|
||||
onCredentialAdded?.()
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{canApiKey && (
|
||||
<AddApiKeyButton
|
||||
pluginPayload={pluginPayload}
|
||||
buttonVariant='ghost'
|
||||
buttonText={t('plugin.auth.addApi')}
|
||||
disabled={disabled}
|
||||
onUpdate={() => {
|
||||
setShowAddMenu(false)
|
||||
onCredentialAdded?.()
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</PortalToFollowElemContent>
|
||||
</PortalToFollowElem>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default memo(CredentialConfigHeader)
|
||||
@@ -0,0 +1,176 @@
|
||||
import {
|
||||
memo,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
} from 'react'
|
||||
import type { ReactNode } from 'react'
|
||||
import {
|
||||
RiArrowDownSLine,
|
||||
RiEqualizer2Line,
|
||||
RiKey2Line,
|
||||
RiUserStarLine,
|
||||
} from '@remixicon/react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import AddOAuthButton from './authorize/add-oauth-button'
|
||||
import AddApiKeyButton from './authorize/add-api-key-button'
|
||||
import type { PluginPayload } from './types'
|
||||
import cn from '@/utils/classnames'
|
||||
import Switch from '@/app/components/base/switch'
|
||||
import {
|
||||
PortalToFollowElem,
|
||||
PortalToFollowElemContent,
|
||||
PortalToFollowElemTrigger,
|
||||
} from '@/app/components/base/portal-to-follow-elem'
|
||||
|
||||
export type EndUserCredentialSectionProps = {
|
||||
pluginPayload: PluginPayload
|
||||
canOAuth?: boolean
|
||||
canApiKey?: boolean
|
||||
disabled?: boolean
|
||||
useEndUserCredentialEnabled?: boolean
|
||||
endUserCredentialType?: string
|
||||
onEndUserCredentialChange?: (enabled: boolean) => void
|
||||
onEndUserCredentialTypeChange?: (type: string) => void
|
||||
onCredentialAdded?: () => void
|
||||
className?: string
|
||||
}
|
||||
|
||||
const EndUserCredentialSection = ({
|
||||
pluginPayload,
|
||||
canOAuth,
|
||||
canApiKey,
|
||||
disabled,
|
||||
useEndUserCredentialEnabled,
|
||||
endUserCredentialType,
|
||||
onEndUserCredentialChange,
|
||||
onEndUserCredentialTypeChange,
|
||||
onCredentialAdded,
|
||||
className,
|
||||
}: EndUserCredentialSectionProps) => {
|
||||
const { t } = useTranslation()
|
||||
const [showEndUserTypeMenu, setShowEndUserTypeMenu] = useState(false)
|
||||
|
||||
const availableEndUserTypes = useMemo(() => {
|
||||
const list: { value: string; label: string; icon: ReactNode }[] = []
|
||||
if (canOAuth) {
|
||||
list.push({
|
||||
value: 'oauth2',
|
||||
label: t('plugin.auth.endUserCredentials.optionOAuth'),
|
||||
icon: <RiEqualizer2Line className='h-4 w-4 text-text-tertiary' />,
|
||||
})
|
||||
}
|
||||
if (canApiKey) {
|
||||
list.push({
|
||||
value: 'api-key',
|
||||
label: t('plugin.auth.endUserCredentials.optionApiKey'),
|
||||
icon: <RiKey2Line className='h-4 w-4 text-text-tertiary' />,
|
||||
})
|
||||
}
|
||||
return list
|
||||
}, [canOAuth, canApiKey, t])
|
||||
|
||||
const endUserCredentialLabel = useMemo(() => {
|
||||
const found = availableEndUserTypes.find(item => item.value === endUserCredentialType)
|
||||
return found?.label || availableEndUserTypes[0]?.label || '-'
|
||||
}, [availableEndUserTypes, endUserCredentialType])
|
||||
|
||||
useEffect(() => {
|
||||
if (!useEndUserCredentialEnabled)
|
||||
return
|
||||
if (!availableEndUserTypes.length)
|
||||
return
|
||||
const isValid = availableEndUserTypes.some(item => item.value === endUserCredentialType)
|
||||
if (!isValid)
|
||||
onEndUserCredentialTypeChange?.(availableEndUserTypes[0].value)
|
||||
}, [useEndUserCredentialEnabled, endUserCredentialType, availableEndUserTypes, onEndUserCredentialTypeChange])
|
||||
|
||||
const handleSelectEndUserType = useCallback((value: string) => {
|
||||
onEndUserCredentialTypeChange?.(value)
|
||||
setShowEndUserTypeMenu(false)
|
||||
}, [onEndUserCredentialTypeChange])
|
||||
|
||||
return (
|
||||
<div className={cn('flex items-start gap-3', className)}>
|
||||
<RiUserStarLine className='mt-0.5 h-4 w-4 shrink-0 text-text-tertiary' />
|
||||
<div className='flex-1 space-y-3'>
|
||||
<div className='flex items-center justify-between gap-3'>
|
||||
<div className='space-y-1'>
|
||||
<div className='system-sm-semibold text-text-primary'>
|
||||
{t('plugin.auth.endUserCredentials.title')}
|
||||
</div>
|
||||
<div className='system-xs-regular text-text-tertiary'>
|
||||
{t('plugin.auth.endUserCredentials.desc')}
|
||||
</div>
|
||||
</div>
|
||||
<Switch
|
||||
size='md'
|
||||
defaultValue={!!useEndUserCredentialEnabled}
|
||||
onChange={onEndUserCredentialChange}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</div>
|
||||
{
|
||||
useEndUserCredentialEnabled && availableEndUserTypes.length > 0 && (
|
||||
<div className='flex items-center justify-between gap-3'>
|
||||
<div className='system-sm-semibold text-text-primary'>
|
||||
{t('plugin.auth.endUserCredentials.typeLabel')}
|
||||
</div>
|
||||
<PortalToFollowElem
|
||||
open={showEndUserTypeMenu}
|
||||
onOpenChange={setShowEndUserTypeMenu}
|
||||
placement='bottom-end'
|
||||
offset={6}
|
||||
>
|
||||
<PortalToFollowElemTrigger asChild>
|
||||
<button
|
||||
type='button'
|
||||
className='border-components-input-border flex h-9 min-w-[190px] items-center justify-between rounded-lg border bg-components-input-bg-normal px-3 text-left text-text-primary shadow-xs hover:bg-components-input-bg-hover'
|
||||
onClick={() => setShowEndUserTypeMenu(v => !v)}
|
||||
>
|
||||
<span className='system-sm-semibold'>{endUserCredentialLabel}</span>
|
||||
<RiArrowDownSLine className='h-4 w-4 text-text-tertiary' />
|
||||
</button>
|
||||
</PortalToFollowElemTrigger>
|
||||
<PortalToFollowElemContent className='z-[120]'>
|
||||
<div className='w-[220px] rounded-xl border border-components-panel-border bg-components-panel-bg shadow-lg'>
|
||||
<div className='flex flex-col gap-1 p-1'>
|
||||
{canOAuth && (
|
||||
<AddOAuthButton
|
||||
pluginPayload={pluginPayload}
|
||||
buttonVariant='ghost'
|
||||
className='w-full justify-between bg-transparent text-text-primary hover:bg-transparent'
|
||||
buttonText={t('plugin.auth.addOAuth')}
|
||||
disabled={disabled}
|
||||
onUpdate={() => {
|
||||
handleSelectEndUserType('oauth2')
|
||||
onCredentialAdded?.()
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{canApiKey && (
|
||||
<AddApiKeyButton
|
||||
pluginPayload={pluginPayload}
|
||||
buttonVariant='ghost'
|
||||
buttonText={t('plugin.auth.addApi')}
|
||||
disabled={disabled}
|
||||
onUpdate={() => {
|
||||
handleSelectEndUserType('api-key')
|
||||
onCredentialAdded?.()
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</PortalToFollowElemContent>
|
||||
</PortalToFollowElem>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default memo(EndUserCredentialSection)
|
||||
@@ -13,6 +13,7 @@ export const usePluginAuth = (pluginPayload: PluginPayload, enable?: boolean) =>
|
||||
const canOAuth = data?.supported_credential_types.includes(CredentialTypeEnum.OAUTH2)
|
||||
const canApiKey = data?.supported_credential_types.includes(CredentialTypeEnum.API_KEY)
|
||||
const invalidPluginCredentialInfo = useInvalidPluginCredentialInfoHook(pluginPayload)
|
||||
const hasOAuthClientConfigured = data?.is_oauth_custom_client_enabled
|
||||
|
||||
return {
|
||||
isAuthorized,
|
||||
@@ -22,5 +23,6 @@ export const usePluginAuth = (pluginPayload: PluginPayload, enable?: boolean) =>
|
||||
disabled: !isCurrentWorkspaceManager,
|
||||
notAllowCustomCredential: data?.allow_custom_token === false,
|
||||
invalidPluginCredentialInfo,
|
||||
hasOAuthClientConfigured: !!hasOAuthClientConfigured,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,10 @@ export { default as PluginAuth } from './plugin-auth'
|
||||
export { default as Authorized } from './authorized'
|
||||
export { default as AuthorizedInNode } from './authorized-in-node'
|
||||
export { default as PluginAuthInAgent } from './plugin-auth-in-agent'
|
||||
export { default as CredentialConfigHeader } from './credential-config-header'
|
||||
export type { CredentialConfigHeaderProps } from './credential-config-header'
|
||||
export { default as EndUserCredentialSection } from './end-user-credential-section'
|
||||
export type { EndUserCredentialSectionProps } from './end-user-credential-section'
|
||||
export { usePluginAuth } from './hooks/use-plugin-auth'
|
||||
export { default as PluginAuthInDataSourceNode } from './plugin-auth-in-datasource-node'
|
||||
export { default as AuthorizedInDataSourceNode } from './authorized-in-data-source-node'
|
||||
|
||||
@@ -3,10 +3,14 @@ import {
|
||||
useCallback,
|
||||
useState,
|
||||
} from 'react'
|
||||
import { RiArrowDownSLine } from '@remixicon/react'
|
||||
import {
|
||||
RiArrowDownSLine,
|
||||
} from '@remixicon/react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Authorize from './authorize'
|
||||
import Authorized from './authorized'
|
||||
import CredentialConfigHeader from './credential-config-header'
|
||||
import EndUserCredentialSection from './end-user-credential-section'
|
||||
import type {
|
||||
Credential,
|
||||
PluginPayload,
|
||||
@@ -20,11 +24,19 @@ type PluginAuthInAgentProps = {
|
||||
pluginPayload: PluginPayload
|
||||
credentialId?: string
|
||||
onAuthorizationItemClick?: (id: string) => void
|
||||
useEndUserCredentialEnabled?: boolean
|
||||
endUserCredentialType?: string
|
||||
onEndUserCredentialChange?: (enabled: boolean) => void
|
||||
onEndUserCredentialTypeChange?: (type: string) => void
|
||||
}
|
||||
const PluginAuthInAgent = ({
|
||||
pluginPayload,
|
||||
credentialId,
|
||||
onAuthorizationItemClick,
|
||||
useEndUserCredentialEnabled,
|
||||
endUserCredentialType,
|
||||
onEndUserCredentialChange,
|
||||
onEndUserCredentialTypeChange,
|
||||
}: PluginAuthInAgentProps) => {
|
||||
const { t } = useTranslation()
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
@@ -36,8 +48,11 @@ const PluginAuthInAgent = ({
|
||||
disabled,
|
||||
invalidPluginCredentialInfo,
|
||||
notAllowCustomCredential,
|
||||
hasOAuthClientConfigured,
|
||||
} = usePluginAuth(pluginPayload, true)
|
||||
|
||||
const configuredDisabled = !!useEndUserCredentialEnabled
|
||||
|
||||
const extraAuthorizationItems: Credential[] = [
|
||||
{
|
||||
id: '__workspace_default__',
|
||||
@@ -94,42 +109,87 @@ const PluginAuthInAgent = ({
|
||||
)
|
||||
}, [credentialId, credentials, t])
|
||||
|
||||
const shouldShowAuthorizeCard = !credentials.length && (canOAuth || canApiKey || hasOAuthClientConfigured)
|
||||
|
||||
return (
|
||||
<>
|
||||
{
|
||||
!isAuthorized && (
|
||||
<Authorize
|
||||
pluginPayload={pluginPayload}
|
||||
canOAuth={canOAuth}
|
||||
canApiKey={canApiKey}
|
||||
disabled={disabled}
|
||||
onUpdate={invalidPluginCredentialInfo}
|
||||
notAllowCustomCredential={notAllowCustomCredential}
|
||||
/>
|
||||
)
|
||||
}
|
||||
{
|
||||
isAuthorized && (
|
||||
<Authorized
|
||||
pluginPayload={pluginPayload}
|
||||
credentials={credentials}
|
||||
canOAuth={canOAuth}
|
||||
canApiKey={canApiKey}
|
||||
disabled={disabled}
|
||||
disableSetDefault
|
||||
onItemClick={handleAuthorizationItemClick}
|
||||
extraAuthorizationItems={extraAuthorizationItems}
|
||||
showItemSelectedIcon
|
||||
renderTrigger={renderTrigger}
|
||||
isOpen={isOpen}
|
||||
onOpenChange={setIsOpen}
|
||||
selectedCredentialId={credentialId || '__workspace_default__'}
|
||||
onUpdate={invalidPluginCredentialInfo}
|
||||
notAllowCustomCredential={notAllowCustomCredential}
|
||||
/>
|
||||
)
|
||||
}
|
||||
</>
|
||||
<div className='border-components-panel-border bg-components-panel-bg'>
|
||||
<div className={cn(configuredDisabled && 'pointer-events-none opacity-50')}>
|
||||
<CredentialConfigHeader
|
||||
pluginPayload={pluginPayload}
|
||||
canOAuth={canOAuth}
|
||||
canApiKey={canApiKey}
|
||||
hasOAuthClientConfigured={hasOAuthClientConfigured}
|
||||
disabled={disabled || configuredDisabled}
|
||||
onCredentialAdded={invalidPluginCredentialInfo}
|
||||
/>
|
||||
</div>
|
||||
<div className={cn(configuredDisabled && 'pointer-events-none opacity-50')}>
|
||||
{
|
||||
!isAuthorized && shouldShowAuthorizeCard && (
|
||||
<div className='rounded-xl bg-background-section px-4 py-4'>
|
||||
<div className='flex w-full justify-center'>
|
||||
<div className='w-full max-w-[520px]'>
|
||||
<Authorize
|
||||
pluginPayload={pluginPayload}
|
||||
canOAuth={canOAuth}
|
||||
canApiKey={canApiKey}
|
||||
disabled={disabled || configuredDisabled}
|
||||
onUpdate={invalidPluginCredentialInfo}
|
||||
notAllowCustomCredential={notAllowCustomCredential}
|
||||
theme='secondary'
|
||||
showDivider={!!(canOAuth && canApiKey)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
{
|
||||
!isAuthorized && !shouldShowAuthorizeCard && (
|
||||
<Authorize
|
||||
pluginPayload={pluginPayload}
|
||||
canOAuth={canOAuth}
|
||||
canApiKey={canApiKey}
|
||||
disabled={disabled || configuredDisabled}
|
||||
onUpdate={invalidPluginCredentialInfo}
|
||||
notAllowCustomCredential={notAllowCustomCredential}
|
||||
/>
|
||||
)
|
||||
}
|
||||
{
|
||||
isAuthorized && (
|
||||
<Authorized
|
||||
pluginPayload={pluginPayload}
|
||||
credentials={credentials}
|
||||
canOAuth={canOAuth}
|
||||
canApiKey={canApiKey}
|
||||
disabled={disabled || configuredDisabled}
|
||||
disableSetDefault
|
||||
onItemClick={handleAuthorizationItemClick}
|
||||
extraAuthorizationItems={extraAuthorizationItems}
|
||||
showItemSelectedIcon
|
||||
renderTrigger={renderTrigger}
|
||||
isOpen={isOpen}
|
||||
onOpenChange={setIsOpen}
|
||||
selectedCredentialId={credentialId || '__workspace_default__'}
|
||||
onUpdate={invalidPluginCredentialInfo}
|
||||
notAllowCustomCredential={notAllowCustomCredential}
|
||||
/>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
<EndUserCredentialSection
|
||||
pluginPayload={pluginPayload}
|
||||
canOAuth={canOAuth}
|
||||
canApiKey={canApiKey}
|
||||
disabled={disabled}
|
||||
useEndUserCredentialEnabled={useEndUserCredentialEnabled}
|
||||
endUserCredentialType={endUserCredentialType}
|
||||
onEndUserCredentialChange={onEndUserCredentialChange}
|
||||
onEndUserCredentialTypeChange={onEndUserCredentialTypeChange}
|
||||
onCredentialAdded={invalidPluginCredentialInfo}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,26 @@
|
||||
import { memo } from 'react'
|
||||
import {
|
||||
memo,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
} from 'react'
|
||||
import {
|
||||
RiAddLine,
|
||||
RiArrowDownSLine,
|
||||
RiKey2Line,
|
||||
} from '@remixicon/react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import {
|
||||
PortalToFollowElem,
|
||||
PortalToFollowElemContent,
|
||||
PortalToFollowElemTrigger,
|
||||
} from '@/app/components/base/portal-to-follow-elem'
|
||||
import Authorize from './authorize'
|
||||
import Authorized from './authorized'
|
||||
import AddApiKeyButton from './authorize/add-api-key-button'
|
||||
import AddOAuthButton from './authorize/add-oauth-button'
|
||||
import EndUserCredentialSection from './end-user-credential-section'
|
||||
import Item from './authorized/item'
|
||||
import type { PluginPayload } from './types'
|
||||
import { usePluginAuth } from './hooks/use-plugin-auth'
|
||||
import cn from '@/utils/classnames'
|
||||
@@ -9,12 +29,23 @@ type PluginAuthProps = {
|
||||
pluginPayload: PluginPayload
|
||||
children?: React.ReactNode
|
||||
className?: string
|
||||
showConnectGuide?: boolean
|
||||
endUserCredentialEnabled?: boolean
|
||||
endUserCredentialType?: string
|
||||
onEndUserCredentialTypeChange?: (type: string) => void
|
||||
onEndUserCredentialChange?: (enabled: boolean) => void
|
||||
}
|
||||
const PluginAuth = ({
|
||||
pluginPayload,
|
||||
children,
|
||||
className,
|
||||
showConnectGuide,
|
||||
endUserCredentialEnabled,
|
||||
endUserCredentialType,
|
||||
onEndUserCredentialTypeChange,
|
||||
onEndUserCredentialChange,
|
||||
}: PluginAuthProps) => {
|
||||
const { t } = useTranslation()
|
||||
const {
|
||||
isAuthorized,
|
||||
canOAuth,
|
||||
@@ -23,12 +54,205 @@ const PluginAuth = ({
|
||||
disabled,
|
||||
invalidPluginCredentialInfo,
|
||||
notAllowCustomCredential,
|
||||
hasOAuthClientConfigured,
|
||||
} = usePluginAuth(pluginPayload, !!pluginPayload.provider)
|
||||
const shouldShowGuide = !!showConnectGuide
|
||||
const [showCredentialPanel, setShowCredentialPanel] = useState(false)
|
||||
const [showAddMenu, setShowAddMenu] = useState(false)
|
||||
const configuredDisabled = !!endUserCredentialEnabled
|
||||
const shouldShowAuthorizeCard = useMemo(() => {
|
||||
const hasCredential = credentials.length > 0
|
||||
const canAdd = canOAuth || canApiKey || hasOAuthClientConfigured
|
||||
return !hasCredential && canAdd
|
||||
}, [credentials.length, canOAuth, canApiKey, hasOAuthClientConfigured])
|
||||
const containerClassName = useMemo(() => {
|
||||
if (showConnectGuide)
|
||||
return className
|
||||
return !isAuthorized ? className : undefined
|
||||
}, [isAuthorized, className, showConnectGuide])
|
||||
|
||||
useEffect(() => {
|
||||
if (isAuthorized)
|
||||
setShowCredentialPanel(false)
|
||||
}, [isAuthorized])
|
||||
|
||||
const credentialList = useMemo(() => {
|
||||
return (
|
||||
<div className={cn(!credentials.length ? 'mt-0' : 'mt-3')}>
|
||||
{
|
||||
credentials.length > 0
|
||||
? (
|
||||
<div className='space-y-1'>
|
||||
{credentials.map(credential => (
|
||||
<Item
|
||||
key={credential.id}
|
||||
credential={credential}
|
||||
disabled
|
||||
disableRename
|
||||
disableEdit
|
||||
disableDelete
|
||||
disableSetDefault
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
: null
|
||||
}
|
||||
</div>
|
||||
)
|
||||
}, [credentials, t])
|
||||
|
||||
const endUserSwitch = (
|
||||
<EndUserCredentialSection
|
||||
className='px-4 py-3'
|
||||
pluginPayload={pluginPayload}
|
||||
canOAuth={canOAuth}
|
||||
canApiKey={canApiKey}
|
||||
disabled={disabled}
|
||||
useEndUserCredentialEnabled={endUserCredentialEnabled}
|
||||
endUserCredentialType={endUserCredentialType}
|
||||
onEndUserCredentialChange={onEndUserCredentialChange}
|
||||
onEndUserCredentialTypeChange={onEndUserCredentialTypeChange}
|
||||
onCredentialAdded={invalidPluginCredentialInfo}
|
||||
/>
|
||||
)
|
||||
|
||||
return (
|
||||
<div className={cn(!isAuthorized && className)}>
|
||||
<div className={cn(containerClassName)}>
|
||||
{
|
||||
!isAuthorized && (
|
||||
shouldShowGuide && (
|
||||
<PortalToFollowElem
|
||||
open={showCredentialPanel}
|
||||
onOpenChange={setShowCredentialPanel}
|
||||
placement='bottom-start'
|
||||
offset={8}
|
||||
triggerPopupSameWidth
|
||||
>
|
||||
<PortalToFollowElemTrigger asChild>
|
||||
<button
|
||||
type='button'
|
||||
className='flex w-full items-center justify-center gap-2 rounded-xl bg-primary-600 px-4 py-3 text-left text-white shadow-xs hover:bg-primary-700'
|
||||
onClick={() => setShowCredentialPanel(v => !v)}
|
||||
>
|
||||
<div className='system-sm-semibold text-white'>
|
||||
{t('plugin.auth.connectCredentials')}
|
||||
</div>
|
||||
<RiArrowDownSLine
|
||||
className={cn(
|
||||
'h-4 w-4 text-white transition-transform',
|
||||
showCredentialPanel && 'rotate-180',
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
</PortalToFollowElemTrigger>
|
||||
<PortalToFollowElemContent className='z-[100]'>
|
||||
<div className='w-[420px] max-w-[calc(100vw-48px)] rounded-2xl border border-divider-subtle bg-components-panel-bg shadow-lg'>
|
||||
<div className='border-b border-divider-subtle px-3 py-3'>
|
||||
<div className='flex items-start justify-between gap-2'>
|
||||
<div className='flex items-start gap-2'>
|
||||
<RiKey2Line className='mt-0.5 h-4 w-4 text-text-tertiary' />
|
||||
<div className='space-y-0.5'>
|
||||
<div className='system-md-semibold text-text-primary'>
|
||||
{t('plugin.auth.configuredCredentials.title')}
|
||||
</div>
|
||||
<div className='system-xs-regular text-text-tertiary'>
|
||||
{t('plugin.auth.configuredCredentials.desc')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<PortalToFollowElem
|
||||
open={showAddMenu}
|
||||
onOpenChange={setShowAddMenu}
|
||||
placement='bottom-end'
|
||||
offset={6}
|
||||
>
|
||||
<PortalToFollowElemTrigger asChild>
|
||||
<button
|
||||
type='button'
|
||||
className={cn(
|
||||
'flex h-9 w-9 items-center justify-center rounded-full bg-primary-600 text-white hover:bg-primary-700',
|
||||
configuredDisabled && 'pointer-events-none opacity-50',
|
||||
)}
|
||||
onClick={() => {
|
||||
setShowCredentialPanel(true)
|
||||
setShowAddMenu(v => !v)
|
||||
}}
|
||||
>
|
||||
<RiAddLine className='h-5 w-5' />
|
||||
</button>
|
||||
</PortalToFollowElemTrigger>
|
||||
<PortalToFollowElemContent className='z-[120]'>
|
||||
<div className='w-[220px] rounded-xl border border-components-panel-border bg-components-panel-bg shadow-lg'>
|
||||
<div className='flex flex-col gap-1 p-1'>
|
||||
{
|
||||
canOAuth && (
|
||||
<AddOAuthButton
|
||||
pluginPayload={pluginPayload}
|
||||
buttonVariant='ghost'
|
||||
className='w-full justify-between bg-transparent text-text-primary hover:bg-transparent'
|
||||
buttonText={t('plugin.auth.addOAuth')}
|
||||
disabled={disabled}
|
||||
onUpdate={() => {
|
||||
setShowAddMenu(false)
|
||||
invalidPluginCredentialInfo()
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
{
|
||||
canApiKey && (
|
||||
<AddApiKeyButton
|
||||
pluginPayload={pluginPayload}
|
||||
buttonVariant='ghost'
|
||||
buttonText={t('plugin.auth.addApi')}
|
||||
disabled={disabled}
|
||||
onUpdate={() => {
|
||||
setShowAddMenu(false)
|
||||
invalidPluginCredentialInfo()
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</PortalToFollowElemContent>
|
||||
</PortalToFollowElem>
|
||||
</div>
|
||||
<div className={cn(configuredDisabled && 'pointer-events-none opacity-50')}>
|
||||
{credentialList}
|
||||
</div>
|
||||
{
|
||||
shouldShowAuthorizeCard && (
|
||||
<div className={cn(
|
||||
'mt-4 flex items-start gap-1.5 rounded-xl bg-background-section px-4 py-8',
|
||||
configuredDisabled && 'pointer-events-none opacity-50',
|
||||
)}>
|
||||
<div className='flex w-full justify-center'>
|
||||
<div className='w-full max-w-[520px]'>
|
||||
<Authorize
|
||||
pluginPayload={pluginPayload}
|
||||
canOAuth={canOAuth}
|
||||
canApiKey={canApiKey}
|
||||
disabled={disabled || configuredDisabled}
|
||||
onUpdate={invalidPluginCredentialInfo}
|
||||
notAllowCustomCredential={notAllowCustomCredential}
|
||||
theme='secondary'
|
||||
showDivider={!!(canOAuth && canApiKey)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
{endUserSwitch}
|
||||
</div>
|
||||
</PortalToFollowElemContent>
|
||||
</PortalToFollowElem>
|
||||
)
|
||||
}
|
||||
{
|
||||
!shouldShowGuide && !isAuthorized && (
|
||||
<Authorize
|
||||
pluginPayload={pluginPayload}
|
||||
canOAuth={canOAuth}
|
||||
|
||||
@@ -210,6 +210,19 @@ const ToolSelector: FC<Props> = ({
|
||||
credential_id: id,
|
||||
} as any)
|
||||
}
|
||||
const handleEndUserCredentialChange = (enabled: boolean) => {
|
||||
onSelect({
|
||||
...value,
|
||||
use_end_user_credentials: enabled,
|
||||
auth_type: enabled ? 'end_user' : 'workspace',
|
||||
} as any)
|
||||
}
|
||||
const handleEndUserCredentialTypeChange = (type: string) => {
|
||||
onSelect({
|
||||
...value,
|
||||
end_user_credential_type: type,
|
||||
} as any)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -323,6 +336,10 @@ const ToolSelector: FC<Props> = ({
|
||||
}}
|
||||
credentialId={value?.credential_id}
|
||||
onAuthorizationItemClick={handleAuthorizationItemClick}
|
||||
useEndUserCredentialEnabled={value?.use_end_user_credentials}
|
||||
endUserCredentialType={value?.end_user_credential_type}
|
||||
onEndUserCredentialChange={handleEndUserCredentialChange}
|
||||
onEndUserCredentialTypeChange={handleEndUserCredentialTypeChange}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
import React from 'react'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import CSVDownload from './index'
|
||||
|
||||
const mockType = { Link: 'mock-link' }
|
||||
let capturedProps: Record<string, unknown> | undefined
|
||||
|
||||
jest.mock('react-papaparse', () => ({
|
||||
useCSVDownloader: () => {
|
||||
const CSVDownloader = ({ children, ...props }: React.PropsWithChildren<Record<string, unknown>>) => {
|
||||
capturedProps = props
|
||||
return <div data-testid="csv-downloader" className={props.className as string}>{children}</div>
|
||||
}
|
||||
return {
|
||||
CSVDownloader,
|
||||
Type: mockType,
|
||||
}
|
||||
},
|
||||
}))
|
||||
|
||||
describe('CSVDownload', () => {
|
||||
const vars = [{ name: 'prompt' }, { name: 'context' }]
|
||||
|
||||
beforeEach(() => {
|
||||
capturedProps = undefined
|
||||
jest.clearAllMocks()
|
||||
})
|
||||
|
||||
test('should render table headers and sample row for each variable', () => {
|
||||
render(<CSVDownload vars={vars} />)
|
||||
|
||||
expect(screen.getByText('share.generation.csvStructureTitle')).toBeInTheDocument()
|
||||
expect(screen.getAllByRole('row')[0].children).toHaveLength(2)
|
||||
expect(screen.getByText('prompt share.generation.field')).toBeInTheDocument()
|
||||
expect(screen.getByText('context share.generation.field')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
test('should configure CSV downloader with template data', () => {
|
||||
render(<CSVDownload vars={vars} />)
|
||||
|
||||
expect(capturedProps?.filename).toBe('template')
|
||||
expect(capturedProps?.type).toBe(mockType.Link)
|
||||
expect(capturedProps?.bom).toBe(true)
|
||||
expect(capturedProps?.data).toEqual([
|
||||
{ prompt: '', context: '' },
|
||||
])
|
||||
expect(screen.getByText('share.generation.downloadTemplate')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,70 @@
|
||||
import React from 'react'
|
||||
import { act, render, screen, waitFor } from '@testing-library/react'
|
||||
import CSVReader from './index'
|
||||
|
||||
let mockAcceptedFile: { name: string } | null = null
|
||||
let capturedHandlers: Record<string, (payload: any) => void> = {}
|
||||
|
||||
jest.mock('react-papaparse', () => ({
|
||||
useCSVReader: () => ({
|
||||
CSVReader: ({ children, ...handlers }: any) => {
|
||||
capturedHandlers = handlers
|
||||
return (
|
||||
<div data-testid="csv-reader-wrapper">
|
||||
{children({
|
||||
getRootProps: () => ({ 'data-testid': 'drop-zone' }),
|
||||
acceptedFile: mockAcceptedFile,
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
},
|
||||
}),
|
||||
}))
|
||||
|
||||
describe('CSVReader', () => {
|
||||
beforeEach(() => {
|
||||
mockAcceptedFile = null
|
||||
capturedHandlers = {}
|
||||
jest.clearAllMocks()
|
||||
})
|
||||
|
||||
test('should display upload instructions when no file selected', async () => {
|
||||
const onParsed = jest.fn()
|
||||
render(<CSVReader onParsed={onParsed} />)
|
||||
|
||||
expect(screen.getByText('share.generation.csvUploadTitle')).toBeInTheDocument()
|
||||
expect(screen.getByText('share.generation.browse')).toBeInTheDocument()
|
||||
|
||||
await act(async () => {
|
||||
capturedHandlers.onUploadAccepted?.({ data: [['row1']] })
|
||||
})
|
||||
expect(onParsed).toHaveBeenCalledWith([['row1']])
|
||||
})
|
||||
|
||||
test('should show accepted file name without extension', () => {
|
||||
mockAcceptedFile = { name: 'batch.csv' }
|
||||
render(<CSVReader onParsed={jest.fn()} />)
|
||||
|
||||
expect(screen.getByText('batch')).toBeInTheDocument()
|
||||
expect(screen.getByText('.csv')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
test('should toggle hover styling on drag events', async () => {
|
||||
render(<CSVReader onParsed={jest.fn()} />)
|
||||
const dragEvent = { preventDefault: jest.fn() } as unknown as DragEvent
|
||||
|
||||
await act(async () => {
|
||||
capturedHandlers.onDragOver?.(dragEvent)
|
||||
})
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('drop-zone')).toHaveClass('border-components-dropzone-border-accent')
|
||||
})
|
||||
|
||||
await act(async () => {
|
||||
capturedHandlers.onDragLeave?.(dragEvent)
|
||||
})
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('drop-zone')).not.toHaveClass('border-components-dropzone-border-accent')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,88 @@
|
||||
import React from 'react'
|
||||
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import RunBatch from './index'
|
||||
import useBreakpoints, { MediaType } from '@/hooks/use-breakpoints'
|
||||
|
||||
jest.mock('@/hooks/use-breakpoints', () => {
|
||||
const actual = jest.requireActual('@/hooks/use-breakpoints')
|
||||
return {
|
||||
__esModule: true,
|
||||
default: jest.fn(),
|
||||
MediaType: actual.MediaType,
|
||||
}
|
||||
})
|
||||
|
||||
let latestOnParsed: ((data: string[][]) => void) | undefined
|
||||
let receivedCSVDownloadProps: Record<string, unknown> | undefined
|
||||
|
||||
jest.mock('./csv-reader', () => (props: { onParsed: (data: string[][]) => void }) => {
|
||||
latestOnParsed = props.onParsed
|
||||
return <div data-testid="csv-reader" />
|
||||
})
|
||||
|
||||
jest.mock('./csv-download', () => (props: { vars: { name: string }[] }) => {
|
||||
receivedCSVDownloadProps = props
|
||||
return <div data-testid="csv-download" />
|
||||
})
|
||||
|
||||
const mockUseBreakpoints = useBreakpoints as jest.Mock
|
||||
|
||||
describe('RunBatch', () => {
|
||||
const vars = [{ name: 'prompt' }]
|
||||
|
||||
beforeEach(() => {
|
||||
mockUseBreakpoints.mockReturnValue(MediaType.pc)
|
||||
latestOnParsed = undefined
|
||||
receivedCSVDownloadProps = undefined
|
||||
jest.clearAllMocks()
|
||||
})
|
||||
|
||||
test('should enable run button after CSV parsed and send data', async () => {
|
||||
const onSend = jest.fn()
|
||||
render(
|
||||
<RunBatch
|
||||
vars={vars}
|
||||
onSend={onSend}
|
||||
isAllFinished
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(receivedCSVDownloadProps?.vars).toEqual(vars)
|
||||
await act(async () => {
|
||||
latestOnParsed?.([['row1']])
|
||||
})
|
||||
|
||||
const runButton = screen.getByRole('button', { name: 'share.generation.run' })
|
||||
await waitFor(() => {
|
||||
expect(runButton).not.toBeDisabled()
|
||||
})
|
||||
|
||||
fireEvent.click(runButton)
|
||||
expect(onSend).toHaveBeenCalledWith([['row1']])
|
||||
})
|
||||
|
||||
test('should keep button disabled and show spinner when results still running on mobile', async () => {
|
||||
mockUseBreakpoints.mockReturnValue(MediaType.mobile)
|
||||
const onSend = jest.fn()
|
||||
const { container } = render(
|
||||
<RunBatch
|
||||
vars={vars}
|
||||
onSend={onSend}
|
||||
isAllFinished={false}
|
||||
/>,
|
||||
)
|
||||
|
||||
await act(async () => {
|
||||
latestOnParsed?.([['row']])
|
||||
})
|
||||
|
||||
const runButton = screen.getByRole('button', { name: 'share.generation.run' })
|
||||
await waitFor(() => {
|
||||
expect(runButton).toBeDisabled()
|
||||
})
|
||||
expect(runButton).toHaveClass('grow')
|
||||
const icon = container.querySelector('svg')
|
||||
expect(icon).toHaveClass('animate-spin')
|
||||
expect(onSend).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user