From 7bd817e02e7f3d5453d94cc88d6d9d30e4ea48f9 Mon Sep 17 00:00:00 2001 From: BryanFauble <17128019+BryanFauble@users.noreply.github.com> Date: Mon, 16 Feb 2026 21:37:38 +0000 Subject: [PATCH 1/6] Bump filelock version to 3.20.3 in setup.cfg --- Pipfile.lock | 864 +++++++++++++++++++++++++++------------------------ setup.cfg | 2 +- 2 files changed, 464 insertions(+), 402 deletions(-) diff --git a/Pipfile.lock b/Pipfile.lock index 1bcdcf74c..3f3ce82c4 100644 --- a/Pipfile.lock +++ b/Pipfile.lock @@ -329,54 +329,54 @@ }, "packaging": { "hashes": [ - "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484", - "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f" + "sha256:00243ae351a257117b6a241061796684b084ed1c516a08c48a3f7e147a9d80b4", + "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529" ], "markers": "python_version >= '3.8'", - "version": "==25.0" + "version": "==26.0" }, "protobuf": { "hashes": [ - "sha256:1f8017c48c07ec5859106533b682260ba3d7c5567b1ca1f24297ce03384d1b4f", - "sha256:2981c58f582f44b6b13173e12bb8656711189c2a70250845f264b877f00b1913", - "sha256:56dc370c91fbb8ac85bc13582c9e373569668a290aa2e66a590c2a0d35ddb9e4", - "sha256:7109dcc38a680d033ffb8bf896727423528db9163be1b6a02d6a49606dcadbfe", - "sha256:7636aad9bb01768870266de5dc009de2d1b936771b38a793f73cbbf279c91c5c", - "sha256:87eb388bd2d0f78febd8f4c8779c79247b26a5befad525008e49a6955787ff3d", - "sha256:8cd7640aee0b7828b6d03ae518b5b4806fdfc1afe8de82f79c3454f8aef29872", - "sha256:b5d3b5625192214066d99b2b605f5783483575656784de223f00a8d00754fc0e", - "sha256:d9b19771ca75935b3a4422957bc518b0cecb978b31d1dd12037b088f6bcc0e43", - "sha256:fc2a0e8b05b180e5fc0dd1559fe8ebdae21a27e81ac77728fb6c42b12c7419b4" + "sha256:3093804752167bcab3998bec9f1048baae6e29505adaf1afd14a37bddede533c", + "sha256:69915a973dd0f60f31a08b8318b73eab2bd6a392c79184b3612226b0a3f8ec02", + "sha256:6ddcac2a081f8b7b9642c09406bc6a4290128fce5f471cddd165960bb9119e5c", + "sha256:8afa18e1d6d20af15b417e728e9f60f3aa108ee76f23c3b2c07a2c3b546d3afd", + "sha256:8f04fa32763dcdb4973d537d6b54e615cc61108c7cb38fe59310c3192d29510a", + "sha256:9b71e0281f36f179d00cbcb119cb19dec4d14a81393e5ea220f64b286173e190", + "sha256:a3157e62729aafb8df6da2c03aa5c0937c7266c626ce11a278b6eb7963c4e37c", + "sha256:a5cb85982d95d906df1e2210e58f8e4f1e3cdc088e52c921a041f9c9a0386de5", + "sha256:cbf16ba3350fb7b889fca858fb215967792dc125b35c7976ca4818bee3521cf0", + "sha256:d71b040839446bac0f4d162e758bea99c8251161dae9d0983a3b88dee345153b" ], "markers": "python_version >= '3.9'", - "version": "==6.33.2" + "version": "==6.33.5" }, "psutil": { "hashes": [ - "sha256:05cc68dbb8c174828624062e73078e7e35406f4ca2d0866c272c2410d8ef06d1", - "sha256:08a2f175e48a898c8eb8eace45ce01777f4785bc744c90aa2cc7f2fa5462a266", - "sha256:0d67c1822c355aa6f7314d92018fb4268a76668a536f133599b91edd48759442", - "sha256:2ceae842a78d1603753561132d5ad1b2f8a7979cb0c283f5b52fb4e6e14b1a79", - "sha256:35630d5af80d5d0d49cfc4d64c1c13838baf6717a13effb35869a5919b854cdf", - "sha256:3fce5f92c22b00cdefd1645aa58ab4877a01679e901555067b1bd77039aa589f", - "sha256:494c513ccc53225ae23eec7fe6e1482f1b8a44674241b54561f755a898650679", - "sha256:5e38404ca2bb30ed7267a46c02f06ff842e92da3bb8c5bfdadbd35a5722314d8", - "sha256:81442dac7abfc2f4f4385ea9e12ddf5a796721c0f6133260687fec5c3780fa49", - "sha256:923f8653416604e356073e6e0bccbe7c09990acef442def2f5640dd0faa9689f", - "sha256:93f3f7b0bb07711b49626e7940d6fe52aa9940ad86e8f7e74842e73189712129", - "sha256:99a4cd17a5fdd1f3d014396502daa70b5ec21bf4ffe38393e152f8e449757d67", - "sha256:ab2b98c9fc19f13f59628d94df5cc4cc4844bc572467d113a8b517d634e362c6", - "sha256:b1b0671619343aa71c20ff9767eced0483e4fc9e1f489d50923738caf6a03c17", - "sha256:b2e953fcfaedcfbc952b44744f22d16575d3aa78eb4f51ae74165b4e96e55f42", - "sha256:ba9f33bb525b14c3ea563b2fd521a84d2fa214ec59e3e6a2858f78d0844dd60d", - "sha256:cfbe6b40ca48019a51827f20d830887b3107a74a79b01ceb8cc8de4ccb17b672", - "sha256:d34d2ca888208eea2b5c68186841336a7f5e0b990edec929be909353a202768a", - "sha256:ea46c0d060491051d39f0d2cff4f98d5c72b288289f57a21556cc7d504db37fc", - "sha256:f7583aec590485b43ca601dd9cea0dcd65bd7bb21d30ef4ddbf4ea6b5ed1bdd3", - "sha256:f78baafb38436d5a128f837fab2d92c276dfb48af01a240b861ae02b2413ada8" + "sha256:0746f5f8d406af344fd547f1c8daa5f5c33dbc293bb8d6a16d80b4bb88f59372", + "sha256:076a2d2f923fd4821644f5ba89f059523da90dc9014e85f8e45a5774ca5bc6f9", + "sha256:11fe5a4f613759764e79c65cf11ebdf26e33d6dd34336f8a337aa2996d71c841", + "sha256:1a571f2330c966c62aeda00dd24620425d4b0cc86881c89861fbc04549e5dc63", + "sha256:1a7b04c10f32cc88ab39cbf606e117fd74721c831c98a27dc04578deb0c16979", + "sha256:1fa4ecf83bcdf6e6c8f4449aff98eefb5d0604bf88cb883d7da3d8d2d909546a", + "sha256:2edccc433cbfa046b980b0df0171cd25bcaeb3a68fe9022db0979e7aa74a826b", + "sha256:7b6d09433a10592ce39b13d7be5a54fbac1d1228ed29abc880fb23df7cb694c9", + "sha256:8c233660f575a5a89e6d4cb65d9f938126312bca76d8fe087b947b3a1aaac9ee", + "sha256:917e891983ca3c1887b4ef36447b1e0873e70c933afc831c6b6da078ba474312", + "sha256:ab486563df44c17f5173621c7b198955bd6b613fb87c71c161f827d3fb149a9b", + "sha256:ae0aefdd8796a7737eccea863f80f81e468a1e4cf14d926bd9b6f5f2d5f90ca9", + "sha256:b0726cecd84f9474419d67252add4ac0cd9811b04d61123054b9fb6f57df6e9e", + "sha256:b58fabe35e80b264a4e3bb23e6b96f9e45a3df7fb7eed419ac0e5947c61e47cc", + "sha256:c7663d4e37f13e884d13994247449e9f8f574bc4655d509c3b95e9ec9e2b9dc1", + "sha256:e452c464a02e7dc7822a05d25db4cde564444a67e58539a00f929c51eddda0cf", + "sha256:e78c8603dcd9a04c7364f1a3e670cea95d51ee865e4efb3556a3a63adef958ea", + "sha256:eb7e81434c8d223ec4a219b5fc1c47d0417b12be7ea866e24fb5ad6e84b3d988", + "sha256:ed0cace939114f62738d808fdcecd4c869222507e266e574799e9c0faa17d486", + "sha256:eed63d3b4d62449571547b60578c5b2c4bcccc5387148db46e0c2313dad0ee00", + "sha256:fd04ef36b4a6d599bbdb225dd1d3f51e00105f6d48a28f006da7f9822f2606d8" ], "markers": "python_version >= '3.6'", - "version": "==7.2.1" + "version": "==7.2.2" }, "requests": { "hashes": [ @@ -387,16 +387,16 @@ "version": "==2.32.5" }, "synapseclient": { - "markers": "python_version < '3.14' and python_version >= '3.9'", + "markers": "python_version >= '3.10' and python_version < '3.15'", "path": "." }, "tqdm": { "hashes": [ - "sha256:26445eca388f82e72884e0d580d5464cd801a3ea01e63e5601bdff9ba6a48de2", - "sha256:f8aef9c52c08c13a65f30ea34f4e5aac3fd1a34959879d7e59e63027286627f2" + "sha256:7d825f03f89244ef73f1d4ce193cb1774a8179fd96f31d7e1dcde62092b960bb", + "sha256:ee1e4c0e59148062281c49d80b25b67771a127c85fc9676d3be5f243206826bf" ], "markers": "python_version >= '3.7'", - "version": "==4.67.1" + "version": "==4.67.3" }, "typing-extensions": { "hashes": [ @@ -545,24 +545,24 @@ }, "babel": { "hashes": [ - "sha256:0c54cffb19f690cdcc52a3b50bcbf71e07a808d1c80d549f2459b9d2cf0afb9d", - "sha256:4d0b53093fdfb4b21c92b5213dba5a1b23885afa8383709427046b21c366e5f2" + "sha256:b80b99a14bd085fcacfa15c9165f651fbb3406e66cc603abf11c5750937c992d", + "sha256:e2b422b277c2b9a9630c1d7903c2a00d0830c409c59ac8cae9081c92f1aeba35" ], "markers": "python_version >= '3.8'", - "version": "==2.17.0" + "version": "==2.18.0" }, "backrefs": { "hashes": [ - "sha256:13eafbc9ccd5222e9c1f0bec563e6d2a6d21514962f11e7fc79872fd56cbc853", - "sha256:2a2ccb96302337ce61ee4717ceacfbf26ba4efb1d55af86564b8bbaeda39cac1", - "sha256:3bba1749aafe1db9b915f00e0dd166cba613b6f788ffd63060ac3485dc9be231", - "sha256:4c9d3dc1e2e558965202c012304f33d4e0e477e1c103663fd2c3cc9bb18b0d05", - "sha256:a9e99b8a4867852cad177a6430e31b0f6e495d65f8c6c134b68c14c3c95bf4b0", - "sha256:c64698c8d2269343d88947c0735cb4b78745bd3ba590e10313fbf3f78c34da5a", - "sha256:e82bba3875ee4430f4de4b6db19429a27275d95a5f3773c57e9e18abc23fd2b7" + "sha256:08aa7fae530c6b2361d7bdcbda1a7c454e330cc9dbcd03f5c23205e430e5c3be", + "sha256:0fdc7b012420b6b144410342caeb8adc54c6866cf12064abc9bb211302e496f8", + "sha256:12df81596ab511f783b7d87c043ce26bc5b0288cf3bb03610fe76b8189282b2b", + "sha256:664e33cd88c6840b7625b826ecf2555f32d491800900f5a541f772c485f7cda7", + "sha256:c3f4b9cb2af8cda0d87ab4f57800b57b95428488477be164dd2b47be54db0c90", + "sha256:e5f805ae09819caa1aa0623b4a83790e7028604aa2b8c73ba602c4454e665de7", + "sha256:f44ff4d48808b243b6c0cdc6231e22195c32f77046018141556c66f8bab72a49" ], "markers": "python_version >= '3.9'", - "version": "==6.1" + "version": "==6.2" }, "bcrypt": { "hashes": [ @@ -635,52 +635,52 @@ }, "black": { "hashes": [ - "sha256:05dd459a19e218078a1f98178c13f861fe6a9a5f88fc969ca4d9b49eb1809783", - "sha256:09524b0e6af8ba7a3ffabdfc7a9922fb9adef60fed008c7cd2fc01f3048e6e6f", - "sha256:0a0953b134f9335c2434864a643c842c44fba562155c738a2a37a4d61f00cad5", - "sha256:0e509c858adf63aa61d908061b52e580c40eae0dfa72415fa47ac01b12e29baf", - "sha256:169506ba91ef21e2e0591563deda7f00030cb466e747c4b09cb0a9dae5db2f43", - "sha256:17dcc893da8d73d8f74a596f64b7c98ef5239c2cd2b053c0f25912c4494bf9ea", - "sha256:1a2f578ae20c19c50a382286ba78bfbeafdf788579b053d8e4980afb079ab9be", - "sha256:2355bbb6c3b76062870942d8cc450d4f8ac71f9c93c40122762c8784df49543f", - "sha256:252678f07f5bac4ff0d0e9b261fbb029fa530cfa206d0a636a34ab445ef8ca9d", - "sha256:274f940c147ddab4442d316b27f9e332ca586d39c85ecf59ebdea82cc9ee8892", - "sha256:31f96b7c98c1ddaeb07dc0f56c652e25bdedaac76d5b68a059d998b57c55594a", - "sha256:48ceb36c16dbc84062740049eef990bb2ce07598272e673c17d1a7720c71c828", - "sha256:51e267458f7e650afed8445dc7edb3187143003d52a1b710c7321aef22aa9655", - "sha256:546eecfe9a3a6b46f9d69d8a642585a6eaf348bcbbc4d87a19635570e02d9f4a", - "sha256:778285d9ea197f34704e3791ea9404cd6d07595745907dd2ce3da7a13627b29b", - "sha256:8d3dd9cea14bff7ddc0eb243c811cdb1a011ebb4800a5f0335a01a68654796a7", - "sha256:9678bd991cc793e81d19aeeae57966ee02909877cb65838ccffef24c3ebac08f", - "sha256:97596189949a8aad13ad12fcbb4ae89330039b96ad6742e6f6b45e75ad5cfd83", - "sha256:9ec77439ef3e34896995503865a85732c94396edcc739f302c5673a2315e1e7f", - "sha256:a05ddeb656534c3e27a05a29196c962877c83fa5503db89e68857d1161ad08a5", - "sha256:a3fa71e3b8dd9f7c6ac4d818345237dfb4175ed3bf37cd5a581dbc4c034f1ec5", - "sha256:b162653ed89eb942758efeb29d5e333ca5bb90e5130216f8369857db5955a7da", - "sha256:bc5b1c09fe3c931ddd20ee548511c64ebf964ada7e6f0763d443947fd1c603ce", - "sha256:c1f68c5eff61f226934be6b5b80296cf6939e5d2f0c2f7d543ea08b204bfaf59", - "sha256:d0cfa263e85caea2cff57d8f917f9f51adae8e20b610e2b23de35b5b11ce691a", - "sha256:d3e1b65634b0e471d07ff86ec338819e2ef860689859ef4501ab7ac290431f9b", - "sha256:f85ba1ad15d446756b4ab5f3044731bf68b777f8f9ac9cdabd2425b97cd9c4e8" + "sha256:101540cb2a77c680f4f80e628ae98bd2bd8812fb9d72ade4f8995c5ff019e82c", + "sha256:1054e8e47ebd686e078c0bb0eaf31e6ce69c966058d122f2c0c950311f9f3ede", + "sha256:1de0f7d01cc894066a1153b738145b194414cc6eeaad8ef4397ac9abacf40f6b", + "sha256:2b807c240b64609cb0e80d2200a35b23c7df82259f80bef1b2c96eb422b4aac9", + "sha256:3cee1487a9e4c640dc7467aaa543d6c0097c391dc8ac74eb313f2fbf9d7a7cb5", + "sha256:53c62883b3f999f14e5d30b5a79bd437236658ad45b2f853906c7cbe79de00af", + "sha256:5e8e75dabb6eb83d064b0db46392b25cabb6e784ea624219736e8985a6b3675d", + "sha256:643d27fb5facc167c0b1b59d0315f2674a6e950341aed0fc05cf307d22bf4954", + "sha256:66912475200b67ef5a0ab665011964bf924745103f51977a78b4fb92a9fc1bf0", + "sha256:6eeca41e70b5f5c84f2f913af857cf2ce17410847e1d54642e658e078da6544f", + "sha256:6f3977a16e347f1b115662be07daa93137259c711e526402aa444d7a88fdc9d4", + "sha256:7ed300200918147c963c87700ccf9966dceaefbbb7277450a8d646fc5646bf24", + "sha256:91a68ae46bf07868963671e4d05611b179c2313301bd756a89ad4e3b3db2325b", + "sha256:9459ad0d6cd483eacad4c6566b0f8e42af5e8b583cee917d90ffaa3778420a0a", + "sha256:9dc8c71656a79ca49b8d3e2ce8103210c9481c57798b48deeb3a8bb02db5f115", + "sha256:a19915ec61f3a8746e8b10adbac4a577c6ba9851fa4a9e9fbfbcf319887a5791", + "sha256:b22b3810451abe359a964cc88121d57f7bce482b53a066de0f1584988ca36e79", + "sha256:ba1d768fbfb6930fc93b0ecc32a43d8861ded16f47a40f14afa9bb04ab93d304", + "sha256:be5e2fe860b9bd9edbf676d5b60a9282994c03fbbd40fe8f5e75d194f96064ca", + "sha256:c5b7713daea9bf943f79f8c3b46f361cc5229e0e604dcef6a8bb6d1c37d9df89", + "sha256:ca699710dece84e3ebf6e92ee15f5b8f72870ef984bf944a57a777a48357c168", + "sha256:d294ac3340eef9c9eb5d29288e96dc719ff269a88e27b396340459dd85da4c58", + "sha256:d62d14ca31c92adf561ebb2e5f2741bf8dea28aef6deb400d49cca011d186c68", + "sha256:dd39eef053e58e60204f2cdf059e2442e2eb08f15989eefe259870f89614c8b6", + "sha256:eb07665d9a907a1a645ee41a0df8a25ffac8ad9c26cdb557b7b88eeeeec934e0", + "sha256:f016baaadc423dc960cdddf9acae679e71ee02c4c341f78f3179d7e4819c095f", + "sha256:fb1dafbbaa3b1ee8b4550a84425aac8874e5f390200f5502cf3aee4a2acb2f14" ], "markers": "python_version >= '3.10'", - "version": "==25.12.0" + "version": "==26.1.0" }, "boto3": { "hashes": [ - "sha256:8ed6ad670a5a2d7f66c1b0d3362791b48392c7a08f78479f5d8ab319a4d9118f", - "sha256:c47a2f40df933e3861fc66fd8d6b87ee36d4361663a7e7ba39a87f5a78b2eae1" + "sha256:2fdf8f5349b130d62576068a6c47b3eec368a70bc28f16d8cce17c5f7e74fc2e", + "sha256:38545d7e6e855fefc8a11e899ccbd6d2c9f64671d6648c2acfb1c78c1057a480" ], "markers": "python_version >= '3.9'", - "version": "==1.42.24" + "version": "==1.42.50" }, "botocore": { "hashes": [ - "sha256:8fca9781d7c84f7ad070fceffaff7179c4aa7a5ffb27b43df9d1d957801e0a8d", - "sha256:be8d1bea64fb91eea08254a1e5fea057e4428d08e61f4e11083a02cafc1f8cc6" + "sha256:3ec7004009d1557a881b1d076d54b5768230849fa9ccdebfd409f0571490e691", + "sha256:de1e128e4898f4e66877bfabbbb03c61f99366f27520442539339e8a74afe3a5" ], "markers": "python_version >= '3.9'", - "version": "==1.42.24" + "version": "==1.42.50" }, "certifi": { "hashes": [ @@ -928,161 +928,170 @@ "toml" ], "hashes": [ - "sha256:0403f647055de2609be776965108447deb8e384fe4a553c119e3ff6bfbab4784", - "sha256:0642eae483cc8c2902e4af7298bf886d605e80f26382124cddc3967c2a3df09e", - "sha256:0b609fc9cdbd1f02e51f67f51e5aee60a841ef58a68d00d5ee2c0faf357481a3", - "sha256:0d2c11f3ea4db66b5cbded23b20185c35066892c67d80ec4be4bab257b9ad1e0", - "sha256:0e42e0ec0cd3e0d851cb3c91f770c9301f48647cb2877cb78f74bdaa07639a79", - "sha256:132718176cc723026d201e347f800cd1a9e4b62ccd3f82476950834dad501c75", - "sha256:16cc1da46c04fb0fb128b4dc430b78fa2aba8a6c0c9f8eb391fd5103409a6ac6", - "sha256:18be793c4c87de2965e1c0f060f03d9e5aff66cfeae8e1dbe6e5b88056ec153f", - "sha256:1a55d509a1dc5a5b708b5dad3b5334e07a16ad4c2185e27b40e4dba796ab7f88", - "sha256:1dcb645d7e34dcbcc96cd7c132b1fc55c39263ca62eb961c064eb3928997363b", - "sha256:2016745cb3ba554469d02819d78958b571792bb68e31302610e898f80dd3a573", - "sha256:228b90f613b25ba0019361e4ab81520b343b622fc657daf7e501c4ed6a2366c0", - "sha256:309ef5706e95e62578cda256b97f5e097916a2c26247c287bbe74794e7150df2", - "sha256:339dc63b3eba969067b00f41f15ad161bf2946613156fb131266d8debc8e44d0", - "sha256:3820778ea1387c2b6a818caec01c63adc5b3750211af6447e8dcfb9b6f08dbba", - "sha256:3d42df8201e00384736f0df9be2ced39324c3907607d17d50d50116c989d84cd", - "sha256:3e7b8bd70c48ffb28461ebe092c2345536fb18bbbf19d287c8913699735f505c", - "sha256:3f2f725aa3e909b3c5fdb8192490bdd8e1495e85906af74fe6e34a2a77ba0673", - "sha256:3fc6a169517ca0d7ca6846c3c5392ef2b9e38896f61d615cb75b9e7134d4ee1e", - "sha256:45980ea19277dc0a579e432aef6a504fe098ef3a9032ead15e446eb0f1191aee", - "sha256:4d010d080c4888371033baab27e47c9df7d6fb28d0b7b7adf85a4a49be9298b3", - "sha256:4de84e71173d4dada2897e5a0e1b7877e5eefbfe0d6a44edee6ce31d9b8ec09e", - "sha256:549d195116a1ba1e1ae2f5ca143f9777800f6636eab917d4f02b5310d6d73461", - "sha256:562ec27dfa3f311e0db1ba243ec6e5f6ab96b1edfcfc6cf86f28038bc4961ce6", - "sha256:57dfc8048c72ba48a8c45e188d811e5efd7e49b387effc8fb17e97936dde5bf6", - "sha256:5899d28b5276f536fcf840b18b61a9fce23cc3aec1d114c44c07fe94ebeaa500", - "sha256:60cfb538fe9ef86e5b2ab0ca8fc8d62524777f6c611dcaf76dc16fbe9b8e698a", - "sha256:623dcc6d7a7ba450bbdbeedbaa0c42b329bdae16491af2282f12a7e809be7eb9", - "sha256:67170979de0dacac3f3097d02b0ad188d8edcea44ccc44aaa0550af49150c7dc", - "sha256:6e73ebb44dca5f708dc871fe0b90cf4cff1a13f9956f747cc87b535a840386f5", - "sha256:6f34591000f06e62085b1865c9bc5f7858df748834662a51edadfd2c3bfe0dd3", - "sha256:724b1b270cb13ea2e6503476e34541a0b1f62280bc997eab443f87790202033d", - "sha256:75a6f4aa904301dab8022397a22c0039edc1f51e90b83dbd4464b8a38dc87842", - "sha256:77545b5dcda13b70f872c3b5974ac64c21d05e65b1590b441c8560115dc3a0d1", - "sha256:776483fd35b58d8afe3acbd9988d5de592ab6da2d2a865edfdbc9fdb43e7c486", - "sha256:77cc258aeb29a3417062758975521eae60af6f79e930d6993555eeac6a8eac29", - "sha256:794f7c05af0763b1bbd1b9e6eff0e52ad068be3b12cd96c87de037b01390c968", - "sha256:868a2fae76dfb06e87291bcbd4dcbcc778a8500510b618d50496e520bd94d9b9", - "sha256:8842af7f175078456b8b17f1b73a0d16a65dcbdc653ecefeb00a56b3c8c298c4", - "sha256:8d9bc218650022a768f3775dd7fdac1886437325d8d295d923ebcfef4892ad5c", - "sha256:8f572d989142e0908e6acf57ad1b9b86989ff057c006d13b76c146ec6a20216a", - "sha256:90480b2134999301eea795b3a9dbf606c6fbab1b489150c501da84a959442465", - "sha256:916abf1ac5cf7eb16bc540a5bf75c71c43a676f5c52fcb9fe75a2bd75fb944e8", - "sha256:92f980729e79b5d16d221038dbf2e8f9a9136afa072f9d5d6ed4cb984b126a09", - "sha256:933082f161bbb3e9f90d00990dc956120f608cdbcaeea15c4d897f56ef4fe416", - "sha256:97ab3647280d458a1f9adb85244e81587505a43c0c7cff851f5116cd2814b894", - "sha256:985b7836931d033570b94c94713c6dba5f9d3ff26045f72c3e5dbc5fe3361e5a", - "sha256:9e549d642426e3579b3f4b92d0431543b012dcb6e825c91619d4e93b7363c3f9", - "sha256:9edd0e01a343766add6817bc448408858ba6b489039eaaa2018474e4001651a4", - "sha256:9ee68b21909686eeb21dfcba2c3b81fee70dcf38b140dcd5aa70680995fa3aa5", - "sha256:9f5e772ed5fef25b3de9f2008fe67b92d46831bd2bc5bdc5dd6bfd06b83b316f", - "sha256:a03a4f3a19a189919c7055098790285cc5c5b0b3976f8d227aea39dbf9f8bfdb", - "sha256:a4d240d260a1aed814790bbe1f10a5ff31ce6c21bc78f0da4a1e8268d6c80dbd", - "sha256:a5a68357f686f8c4d527a2dc04f52e669c2fc1cbde38f6f7eb6a0e58cbd17cae", - "sha256:a998cc0aeeea4c6d5622a3754da5a493055d2d95186bad877b0a34ea6e6dbe0a", - "sha256:b67e47c5595b9224599016e333f5ec25392597a89d5744658f837d204e16c63e", - "sha256:b6f3b96617e9852703f5b633ea01315ca45c77e879584f283c44127f0f1ec564", - "sha256:b7593fe7eb5feaa3fbb461ac79aac9f9fc0387a5ca8080b0c6fe2ca27b091afd", - "sha256:bb3f6562e89bad0110afbe64e485aac2462efdce6232cdec7862a095dc3412f6", - "sha256:bb4f8c3c9a9f34423dba193f241f617b08ffc63e27f67159f60ae6baf2dcfe0f", - "sha256:bd63e7b74661fed317212fab774e2a648bc4bb09b35f25474f8e3325d2945cd7", - "sha256:be753b225d159feb397bd0bf91ae86f689bad0da09d3b301478cd39b878ab31a", - "sha256:bf100a3288f9bb7f919b87eb84f87101e197535b9bd0e2c2b5b3179633324fee", - "sha256:c223d078112e90dc0e5c4e35b98b9584164bea9fbbd221c0b21c5241f6d51b62", - "sha256:c3d8c679607220979434f494b139dfb00131ebf70bb406553d69c1ff01a5c33d", - "sha256:c43257717611ff5e9a1d79dce8e47566235ebda63328718d9b65dd640bc832ef", - "sha256:c832ec92c4499ac463186af72f9ed4d8daec15499b16f0a879b0d1c8e5cf4a3b", - "sha256:c8e2706ceb622bc63bac98ebb10ef5da80ed70fbd8a7999a5076de3afaef0fb1", - "sha256:cb237bfd0ef4d5eb6a19e29f9e528ac67ac3be932ea6b44fb6cc09b9f3ecff78", - "sha256:ccd7a6fca48ca9c131d9b0a2972a581e28b13416fc313fb98b6d24a03ce9a398", - "sha256:d10a2ed46386e850bb3de503a54f9fe8192e5917fcbb143bfef653a9355e9a53", - "sha256:d1443ba9acbb593fa7c1c29e011d7c9761545fe35e7652e85ce7f51a16f7e08d", - "sha256:d2287ac9360dec3837bfdad969963a5d073a09a85d898bd86bea82aa8876ef3c", - "sha256:d3c9f051b028810f5a87c88e5d6e9af3c0ff32ef62763bf15d29f740453ca909", - "sha256:d72140ccf8a147e94274024ff6fd8fb7811354cf7ef88b1f0a988ebaa5bc774f", - "sha256:d938b4a840fb1523b9dfbbb454f652967f18e197569c32266d4d13f37244c3d9", - "sha256:db622b999ffe49cb891f2fff3b340cdc2f9797d01a0a202a0973ba2562501d90", - "sha256:e09fbecc007f7b6afdfb3b07ce5bd9f8494b6856dd4f577d26c66c391b829851", - "sha256:e1fa280b3ad78eea5be86f94f461c04943d942697e0dac889fa18fff8f5f9147", - "sha256:e4f18eca6028ffa62adbd185a8f1e1dd242f2e68164dba5c2b74a5204850b4cf", - "sha256:e825dbb7f84dfa24663dd75835e7257f8882629fc11f03ecf77d84a75134b864", - "sha256:eaecf47ef10c72ece9a2a92118257da87e460e113b83cc0d2905cbbe931792b4", - "sha256:ef6688db9bf91ba111ae734ba6ef1a063304a881749726e0d3575f5c10a9facf", - "sha256:f398ba4df52d30b1763f62eed9de5620dcde96e6f491f4c62686736b155aa6e4", - "sha256:f80e2bb21bfab56ed7405c2d79d34b5dc0bc96c2c1d2a067b643a09fb756c43a", - "sha256:f83351e0f7dcdb14d7326c3d8d8c4e915fa685cbfdc6281f9470d97a04e9dfe4", - "sha256:f8dca5590fec7a89ed6826fce625595279e586ead52e9e958d3237821fbc750c", - "sha256:fa3edde1aa8807de1d05934982416cb3ec46d1d4d91e280bcce7cca01c507992", - "sha256:fea07c1a39a22614acb762e3fbbb4011f65eedafcb2948feeef641ac78b4ee5c", - "sha256:ff10896fa55167371960c5908150b434b71c876dfab97b69478f22c8b445ea19", - "sha256:ff86d4e85188bba72cfb876df3e11fa243439882c55957184af44a35bd5880b7", - "sha256:ffed1e4980889765c84a5d1a566159e363b71d6b6fbaf0bebc9d3c30bc016766" + "sha256:01d4cbc3c283a17fc1e42d614a119f7f438eabb593391283adca8dc86eff1246", + "sha256:02231499b08dabbe2b96612993e5fc34217cdae907a51b906ac7fca8027a4459", + "sha256:0dd7ab8278f0d58a0128ba2fca25824321f05d059c1441800e934ff2efa52129", + "sha256:0e086334e8537ddd17e5f16a344777c1ab8194986ec533711cbe6c41cde841b6", + "sha256:0fc31c787a84f8cd6027eba44010517020e0d18487064cd3d8968941856d1415", + "sha256:14375934243ee05f56c45393fe2ce81fe5cc503c07cee2bdf1725fb8bef3ffaf", + "sha256:1731dc33dc276dafc410a885cbf5992f1ff171393e48a21453b78727d090de80", + "sha256:19bc3c88078789f8ef36acb014d7241961dbf883fd2533d18cb1e7a5b4e28b11", + "sha256:1af1641e57cf7ba1bd67d677c9abdbcd6cc2ab7da3bca7fa1e2b7e50e65f2ad0", + "sha256:1d4be36a5114c499f9f1f9195e95ebf979460dbe2d88e6816ea202010ba1c34b", + "sha256:200dea7d1e8095cc6e98cdabe3fd1d21ab17d3cee6dab00cadbb2fe35d9c15b9", + "sha256:23e3f687cf945070d1c90f85db66d11e3025665d8dafa831301a0e0038f3db9b", + "sha256:2421d591f8ca05b308cf0092807308b2facbefe54af7c02ac22548b88b95c98f", + "sha256:245e37f664d89861cf2329c9afa2c1fe9e6d4e1a09d872c947e70718aeeac505", + "sha256:25381386e80ae727608e662474db537d4df1ecd42379b5ba33c84633a2b36d47", + "sha256:25a41c3104d08edb094d9db0d905ca54d0cd41c928bb6be3c4c799a54753af55", + "sha256:296f8b0af861d3970c2a4d8c91d48eb4dd4771bcef9baedec6a9b515d7de3def", + "sha256:29e3220258d682b6226a9b0925bc563ed9a1ebcff3cad30f043eceea7eaf2689", + "sha256:2a09cfa6a5862bc2fc6ca7c3def5b2926194a56b8ab78ffcf617d28911123012", + "sha256:2b0f6ccf3dbe577170bebfce1318707d0e8c3650003cb4b3a9dd744575daa8b5", + "sha256:2c048ea43875fbf8b45d476ad79f179809c590ec7b79e2035c662e7afa3192e3", + "sha256:2cb0f1e000ebc419632bbe04366a8990b6e32c4e0b51543a6484ffe15eaeda95", + "sha256:2fa8d5f8de70688a28240de9e139fa16b153cc3cbb01c5f16d88d6505ebdadf9", + "sha256:300deaee342f90696ed186e3a00c71b5b3d27bffe9e827677954f4ee56969601", + "sha256:30b8d0512f2dc8c8747557e8fb459d6176a2c9e5731e2b74d311c03b78451997", + "sha256:33901f604424145c6e9c2398684b92e176c0b12df77d52db81c20abd48c3794c", + "sha256:3599eb3992d814d23b35c536c28df1a882caa950f8f507cef23d1cbf334995ac", + "sha256:391ee8f19bef69210978363ca930f7328081c6a0152f1166c91f0b5fdd2a773c", + "sha256:3998e5a32e62fdf410c0dbd3115df86297995d6e3429af80b8798aad894ca7aa", + "sha256:3c06f0f1337c667b971ca2f975523347e63ec5e500b9aa5882d91931cd3ef750", + "sha256:40aa8808140e55dc022b15d8aa7f651b6b3d68b365ea0398f1441e0b04d859c3", + "sha256:40d74da8e6c4b9ac18b15331c4b5ebc35a17069410cad462ad4f40dcd2d50c0d", + "sha256:4223b4230a376138939a9173f1bdd6521994f2aff8047fae100d6d94d50c5a12", + "sha256:48685fee12c2eb3b27c62f2658e7ea21e9c3239cba5a8a242801a0a3f6a8c62a", + "sha256:4c7d3cc01e7350f2f0f6f7036caaf5673fb56b6998889ccfe9e1c1fe75a9c932", + "sha256:4e83efc079eb39480e6346a15a1bcb3e9b04759c5202d157e1dd4303cd619356", + "sha256:4fc7fa81bbaf5a02801b65346c8b3e657f1d93763e58c0abdf7c992addd81a92", + "sha256:53d133df809c743eb8bce33b24bcababb371f4441340578cd406e084d94a6148", + "sha256:590c0ed4bf8e85f745e6b805b2e1c457b2e33d5255dd9729743165253bc9ad39", + "sha256:5b856a8ccf749480024ff3bd7310adaef57bf31fd17e1bfc404b7940b6986634", + "sha256:65dfcbe305c3dfe658492df2d85259e0d79ead4177f9ae724b6fb245198f55d6", + "sha256:6f01afcff62bf9a08fb32b2c1d6e924236c0383c02c790732b6537269e466a72", + "sha256:6fdef321fdfbb30a197efa02d48fcd9981f0d8ad2ae8903ac318adc653f5df98", + "sha256:71ca20079dd8f27fcf808817e281e90220475cd75115162218d0e27549f95fef", + "sha256:725d985c5ab621268b2edb8e50dfe57633dc69bda071abc470fed55a14935fd3", + "sha256:75eab1ebe4f2f64d9509b984f9314d4aa788540368218b858dad56dc8f3e5eb9", + "sha256:75fcd519f2a5765db3f0e391eb3b7d150cce1a771bf4c9f861aeab86c767a3c0", + "sha256:76451d1978b95ba6507a039090ba076105c87cc76fc3efd5d35d72093964d49a", + "sha256:784fc3cf8be001197b652d51d3fd259b1e2262888693a4636e18879f613a62a9", + "sha256:78cdf0d578b15148b009ccf18c686aa4f719d887e76e6b40c38ffb61d264a552", + "sha256:79be69cf7f3bf9b0deeeb062eab7ac7f36cd4cc4c4dd694bd28921ba4d8596cc", + "sha256:79e73a76b854d9c6088fe5d8b2ebe745f8681c55f7397c3c0a016192d681045f", + "sha256:7b322db1284a2ed3aa28ffd8ebe3db91c929b7a333c0820abec3d838ef5b3525", + "sha256:7d41eead3cc673cbd38a4417deb7fd0b4ca26954ff7dc6078e33f6ff97bed940", + "sha256:7eda778067ad7ffccd23ecffce537dface96212576a07924cbf0d8799d2ded5a", + "sha256:7f57b33491e281e962021de110b451ab8a24182589be17e12a22c79047935e23", + "sha256:8041b6c5bfdc03257666e9881d33b1abc88daccaf73f7b6340fb7946655cd10f", + "sha256:8248977c2e33aecb2ced42fef99f2d319e9904a36e55a8a68b69207fb7e43edc", + "sha256:845f352911777a8e722bfce168958214951e07e47e5d5d9744109fa5fe77f79b", + "sha256:85480adfb35ffc32d40918aad81b89c69c9cc5661a9b8a81476d3e645321a056", + "sha256:8e264226ec98e01a8e1054314af91ee6cde0eacac4f465cc93b03dbe0bce2fd7", + "sha256:8e798c266c378da2bd819b0677df41ab46d78065fb2a399558f3f6cae78b2fbb", + "sha256:9181a3ccead280b828fae232df12b16652702b49d41e99d657f46cc7b1f6ec7a", + "sha256:9351229c8c8407645840edcc277f4a2d44814d1bc34a2128c11c2a031d45a5dd", + "sha256:93550784d9281e374fb5a12bf1324cc8a963fd63b2d2f223503ef0fd4aa339ea", + "sha256:9401ebc7ef522f01d01d45532c68c5ac40fb27113019b6b7d8b208f6e9baa126", + "sha256:94eb63f9b363180aff17de3e7c8760c3ba94664ea2695c52f10111244d16a299", + "sha256:9d107aff57a83222ddbd8d9ee705ede2af2cc926608b57abed8ef96b50b7e8f9", + "sha256:a32ebc02a1805adf637fc8dec324b5cdacd2e493515424f70ee33799573d661b", + "sha256:a3aa4e7b9e416774b21797365b358a6e827ffadaaca81b69ee02946852449f00", + "sha256:a6f94a7d00eb18f1b6d403c91a88fd58cfc92d4b16080dfdb774afc8294469bf", + "sha256:aa3feb8db2e87ff5e6d00d7e1480ae241876286691265657b500886c98f38bda", + "sha256:ad27098a189e5838900ce4c2a99f2fe42a0bf0c2093c17c69b45a71579e8d4a2", + "sha256:ae4578f8528569d3cf303fef2ea569c7f4c4059a38c8667ccef15c6e1f118aa5", + "sha256:b1ec7b6b6e93255f952e27ab58fbc68dcc468844b16ecbee881aeb29b6ab4d8d", + "sha256:b507778ae8a4c915436ed5c2e05b4a6cecfa70f734e19c22a005152a11c7b6a9", + "sha256:b66a2da594b6068b48b2692f043f35d4d3693fb639d5ea8b39533c2ad9ac3ab9", + "sha256:b720ce6a88a2755f7c697c23268ddc47a571b88052e6b155224347389fdf6a3b", + "sha256:b7b38448866e83176e28086674fe7368ab8590e4610fb662b44e345b86d63ffa", + "sha256:b8eb931ee8e6d8243e253e5ed7336deea6904369d2fd8ae6e43f68abbf167092", + "sha256:bb28c0f2cf2782508a40cec377935829d5fcc3ad9a3681375af4e84eb34b6b58", + "sha256:bd60d4fe2f6fa7dff9223ca1bbc9f05d2b6697bc5961072e5d3b952d46e1b1ea", + "sha256:c35eb28c1d085eb7d8c9b3296567a1bebe03ce72962e932431b9a61f28facf26", + "sha256:c4240e7eded42d131a2d2c4dec70374b781b043ddc79a9de4d55ca71f8e98aea", + "sha256:caa421e2684e382c5d8973ac55e4f36bed6821a9bad5c953494de960c74595c9", + "sha256:d490ba50c3f35dd7c17953c68f3270e7ccd1c6642e2d2afe2d8e720b98f5a053", + "sha256:d65b2d373032411e86960604dc4edac91fdfb5dca539461cf2cbe78327d1e64f", + "sha256:dae88bc0fc77edaa65c14be099bd57ee140cf507e6bfdeea7938457ab387efb0", + "sha256:de6defc1c9badbf8b9e67ae90fd00519186d6ab64e5cc5f3d21359c2a9b2c1d3", + "sha256:e101609bcbbfb04605ea1027b10dc3735c094d12d40826a60f897b98b1c30256", + "sha256:e24f9156097ff9dc286f2f913df3a7f63c0e333dcafa3c196f2c18b4175ca09a", + "sha256:e2f25215f1a359ab17320b47bcdaca3e6e6356652e8256f2441e4ef972052903", + "sha256:e5c8f6ed1e61a8b2dcdf31eb0b9bbf0130750ca79c1c49eb898e2ad86f5ccc91", + "sha256:e6f70dec1cc557e52df5306d051ef56003f74d56e9c4dd7ddb07e07ef32a84dd", + "sha256:e856bf6616714c3a9fbc270ab54103f4e685ba236fa98c054e8f87f266c93505", + "sha256:e87f6c587c3f34356c3759f0420693e35e7eb0e2e41e4c011cb6ec6ecbbf1db7", + "sha256:eb30bf180de3f632cd043322dad5751390e5385108b2807368997d1a92a509d0", + "sha256:eb88b316ec33760714a4720feb2816a3a59180fd58c1985012054fa7aebee4c2", + "sha256:eb9078108fbf0bcdde37c3f4779303673c2fa1fe8f7956e68d447d0dd426d38a", + "sha256:ecae9737b72408d6a950f7e525f30aca12d4bd8dd95e37342e5beb3a2a8c4f71", + "sha256:ee756f00726693e5ba94d6df2bdfd64d4852d23b09bb0bc700e3b30e6f333985", + "sha256:f4594c67d8a7c89cf922d9df0438c7c7bb022ad506eddb0fdb2863359ff78242", + "sha256:f53d492307962561ac7de4cd1de3e363589b000ab69617c6156a16ba7237998d", + "sha256:fb07dc5da7e849e2ad31a5d74e9bece81f30ecf5a42909d0a695f8bd1874d6af", + "sha256:fb26a934946a6afe0e326aebe0730cdff393a8bc0bbb65a2f41e30feddca399c", + "sha256:fdfc1e28e7c7cdce44985b3043bc13bbd9c747520f94a4d7164af8260b3d91f0" ], "markers": "python_version >= '3.10'", - "version": "==7.13.1" + "version": "==7.13.4" }, "cryptography": { "hashes": [ - "sha256:00a5e7e87938e5ff9ff5447ab086a5706a957137e6e433841e9d24f38a065217", - "sha256:01ca9ff2885f3acc98c29f1860552e37f6d7c7d013d7334ff2a9de43a449315d", - "sha256:09859af8466b69bc3c27bdf4f5d84a665e0f7ab5088412e9e2ec49758eca5cbc", - "sha256:0abf1ffd6e57c67e92af68330d05760b7b7efb243aab8377e583284dbab72c71", - "sha256:1000713389b75c449a6e979ffc7dcc8ac90b437048766cef052d4d30b8220971", - "sha256:109d4ddfadf17e8e7779c39f9b18111a09efb969a301a31e987416a0191ed93a", - "sha256:10b01676fc208c3e6feeb25a8b83d81767e8059e1fe86e1dc62d10a3018fa926", - "sha256:10ca84c4668d066a9878890047f03546f3ae0a6b8b39b697457b7757aaf18dbc", - "sha256:15ab9b093e8f09daab0f2159bb7e47532596075139dd74365da52ecc9cb46c5d", - "sha256:191bb60a7be5e6f54e30ba16fdfae78ad3a342a0599eb4193ba88e3f3d6e185b", - "sha256:22d7e97932f511d6b0b04f2bfd818d73dcd5928db509460aaf48384778eb6d20", - "sha256:23b1a8f26e43f47ceb6d6a43115f33a5a37d57df4ea0ca295b780ae8546e8044", - "sha256:36e627112085bb3b81b19fed209c05ce2a52ee8b15d161b7c643a7d5a88491f3", - "sha256:39b6755623145ad5eff1dab323f4eae2a32a77a7abef2c5089a04a3d04366715", - "sha256:3b51b8ca4f1c6453d8829e1eb7299499ca7f313900dd4d89a24b8b87c0a780d4", - "sha256:402b58fc32614f00980b66d6e56a5b4118e6cb362ae8f3fda141ba4689bd4506", - "sha256:416260257577718c05135c55958b674000baef9a1c7d9e8f306ec60d71db850f", - "sha256:46acf53b40ea38f9c6c229599a4a13f0d46a6c3fa9ef19fc1a124d62e338dfa0", - "sha256:4b7387121ac7d15e550f5cb4a43aef2559ed759c35df7336c402bb8275ac9683", - "sha256:50fc3343ac490c6b08c0cf0d704e881d0d660be923fd3076db3e932007e726e3", - "sha256:516ea134e703e9fe26bcd1277a4b59ad30586ea90c365a87781d7887a646fe21", - "sha256:549e234ff32571b1f4076ac269fcce7a808d3bf98b76c8dd560e42dbc66d7d91", - "sha256:5d7f93296ee28f68447397bf5198428c9aeeab45705a55d53a6343455dcb2c3c", - "sha256:5ecfccd2329e37e9b7112a888e76d9feca2347f12f37918facbb893d7bb88ee8", - "sha256:6276eb85ef938dc035d59b87c8a7dc559a232f954962520137529d77b18ff1df", - "sha256:6b5063083824e5509fdba180721d55909ffacccc8adbec85268b48439423d78c", - "sha256:6eae65d4c3d33da080cff9c4ab1f711b15c1d9760809dad6ea763f3812d254cb", - "sha256:6f61efb26e76c45c4a227835ddeae96d83624fb0d29eb5df5b96e14ed1a0afb7", - "sha256:71e842ec9bc7abf543b47cf86b9a743baa95f4677d22baa4c7d5c69e49e9bc04", - "sha256:760f83faa07f8b64e9c33fc963d790a2edb24efb479e3520c14a45741cd9b2db", - "sha256:78a97cf6a8839a48c49271cdcbd5cf37ca2c1d6b7fdd86cc864f302b5e9bf459", - "sha256:7ce938a99998ed3c8aa7e7272dca1a610401ede816d36d0693907d863b10d9ea", - "sha256:8a6e050cb6164d3f830453754094c086ff2d0b2f3a897a1d9820f6139a1f0914", - "sha256:9394673a9f4de09e28b5356e7fff97d778f8abad85c9d5ac4a4b7e25a0de7717", - "sha256:94cd0549accc38d1494e1f8de71eca837d0509d0d44bf11d158524b0e12cebf9", - "sha256:a04bee9ab6a4da801eb9b51f1b708a1b5b5c9eb48c03f74198464c66f0d344ac", - "sha256:a23582810fedb8c0bc47524558fb6c56aac3fc252cb306072fd2815da2a47c32", - "sha256:a2c0cd47381a3229c403062f764160d57d4d175e022c1df84e168c6251a22eec", - "sha256:a8b17438104fed022ce745b362294d9ce35b4c2e45c1d958ad4a4b019285f4a1", - "sha256:a9a3008438615669153eb86b26b61e09993921ebdd75385ddd748702c5adfddb", - "sha256:b02cf04496f6576afffef5ddd04a0cb7d49cf6be16a9059d793a30b035f6b6ac", - "sha256:b419ae593c86b87014b9be7396b385491ad7f320bde96826d0dd174459e54665", - "sha256:c0a7bb1a68a5d3471880e264621346c48665b3bf1c3759d682fc0864c540bd9e", - "sha256:c70cc23f12726be8f8bc72e41d5065d77e4515efae3690326764ea1b07845cfb", - "sha256:c8daeb2d2174beb4575b77482320303f3d39b8e81153da4f0fb08eb5fe86a6c5", - "sha256:cb3d760a6117f621261d662bccc8ef5bc32ca673e037c83fbe565324f5c46936", - "sha256:d55f3dffadd674514ad19451161118fd010988540cee43d8bc20675e775925de", - "sha256:d89c3468de4cdc4f08a57e214384d0471911a3830fcdaf7a8cc587e42a866372", - "sha256:db391fa7c66df6762ee3f00c95a89e6d428f4d60e7abc8328f4fe155b5ac6e54", - "sha256:dfb781ff7eaa91a6f7fd41776ec37c5853c795d3b358d4896fdbb5df168af422", - "sha256:e5bf0ed4490068a2e72ac03d786693adeb909981cc596425d09032d372bcc849", - "sha256:e7aec276d68421f9574040c26e2a7c3771060bc0cff408bae1dcb19d3ab1e63c", - "sha256:ef639cb3372f69ec44915fafcd6698b6cc78fbe0c2ea41be867f6ed612811963", - "sha256:f260d0d41e9b4da1ed1e0f1ce571f97fe370b152ab18778e9e8f67d6af432018" + "sha256:02f547fce831f5096c9a567fd41bc12ca8f11df260959ecc7c3202555cc47a72", + "sha256:039917b0dc418bb9f6edce8a906572d69e74bd330b0b3fea4f79dab7f8ddd235", + "sha256:1abfdb89b41c3be0365328a410baa9df3ff8a9110fb75e7b52e66803ddabc9a9", + "sha256:2ae6971afd6246710480e3f15824ed3029a60fc16991db250034efd0b9fb4356", + "sha256:2b7a67c9cd56372f3249b39699f2ad479f6991e62ea15800973b956f4b73e257", + "sha256:351695ada9ea9618b3500b490ad54c739860883df6c1f555e088eaf25b1bbaad", + "sha256:38946c54b16c885c72c4f59846be9743d699eee2b69b6988e0a00a01f46a61a4", + "sha256:3b4995dc971c9fb83c25aa44cf45f02ba86f71ee600d81091c2f0cbae116b06c", + "sha256:3ce58ba46e1bc2aac4f7d9290223cead56743fa6ab94a5d53292ffaac6a91614", + "sha256:3ee190460e2fbe447175cda91b88b84ae8322a104fc27766ad09428754a618ed", + "sha256:4108d4c09fbbf2789d0c926eb4152ae1760d5a2d97612b92d508d96c861e4d31", + "sha256:420d0e909050490d04359e7fdb5ed7e667ca5c3c402b809ae2563d7e66a92229", + "sha256:47fb8a66058b80e509c47118ef8a75d14c455e81ac369050f20ba0d23e77fee0", + "sha256:4c3341037c136030cb46e4b1e17b7418ea4cbd9dd207e4a6f3b2b24e0d4ac731", + "sha256:4d7e3d356b8cd4ea5aff04f129d5f66ebdc7b6f8eae802b93739ed520c47c79b", + "sha256:4d8ae8659ab18c65ced284993c2265910f6c9e650189d4e3f68445ef82a810e4", + "sha256:4e817a8920bfbcff8940ecfd60f23d01836408242b30f1a708d93198393a80b4", + "sha256:50bfb6925eff619c9c023b967d5b77a54e04256c4281b0e21336a130cd7fc263", + "sha256:556e106ee01aa13484ce9b0239bca667be5004efb0aabbed28d353df86445595", + "sha256:582f5fcd2afa31622f317f80426a027f30dc792e9c80ffee87b993200ea115f1", + "sha256:5be7bf2fb40769e05739dd0046e7b26f9d4670badc7b032d6ce4db64dddc0678", + "sha256:60ee7e19e95104d4c03871d7d7dfb3d22ef8a9b9c6778c94e1c8fcc8365afd48", + "sha256:61aa400dce22cb001a98014f647dc21cda08f7915ceb95df0c9eaf84b4b6af76", + "sha256:68f68d13f2e1cb95163fa3b4db4bf9a159a418f5f6e7242564fc75fcae667fd0", + "sha256:7d1f30a86d2757199cb2d56e48cce14deddf1f9c95f1ef1b64ee91ea43fe2e18", + "sha256:7d731d4b107030987fd61a7f8ab512b25b53cef8f233a97379ede116f30eb67d", + "sha256:803812e111e75d1aa73690d2facc295eaefd4439be1023fefc4995eaea2af90d", + "sha256:80a8d7bfdf38f87ca30a5391c0c9ce4ed2926918e017c29ddf643d0ed2778ea1", + "sha256:8293f3dea7fc929ef7240796ba231413afa7b68ce38fd21da2995549f5961981", + "sha256:8456928655f856c6e1533ff59d5be76578a7157224dbd9ce6872f25055ab9ab7", + "sha256:890bcb4abd5a2d3f852196437129eb3667d62630333aacc13dfd470fad3aaa82", + "sha256:94a76daa32eb78d61339aff7952ea819b1734b46f73646a07decb40e5b3448e2", + "sha256:9f16fbdf4da055efb21c22d81b89f155f02ba420558db21288b3d0035bafd5f4", + "sha256:a3d1fae9863299076f05cb8a778c467578262fae09f9dc0ee9b12eb4268ce663", + "sha256:a3d507bb6a513ca96ba84443226af944b0f7f47dcc9a399d110cd6146481d24c", + "sha256:abace499247268e3757271b2f1e244b36b06f8515cf27c4d49468fc9eb16e93d", + "sha256:ba2a27ff02f48193fc4daeadf8ad2590516fa3d0adeeb34336b96f7fa64c1e3a", + "sha256:bc84e875994c3b445871ea7181d424588171efec3e185dced958dad9e001950a", + "sha256:bfd56bb4b37ed4f330b82402f6f435845a5f5648edf1ad497da51a8452d5d62d", + "sha256:c18ff11e86df2e28854939acde2d003f7984f721eba450b56a200ad90eeb0e6b", + "sha256:c3bcce8521d785d510b2aad26ae2c966092b7daa8f45dd8f44734a104dc0bc1a", + "sha256:c4143987a42a2397f2fc3b4d7e3a7d313fbe684f67ff443999e803dd75a76826", + "sha256:c69fd885df7d089548a42d5ec05be26050ebcd2283d89b3d30676eb32ff87dee", + "sha256:ced80795227d70549a411a4ab66e8ce307899fad2220ce5ab2f296e687eacde9", + "sha256:d66e421495fdb797610a08f43b05269e0a5ea7f5e652a89bfd5a7d3c1dee3648", + "sha256:d861ee9e76ace6cf36a6a89b959ec08e7bc2493ee39d07ffe5acb23ef46d27da", + "sha256:e9251e3be159d1020c4030bd2e5f84d6a43fe54b6c19c12f51cde9542a2817b2", + "sha256:f145bba11b878005c496e93e257c1e88f154d278d2638e6450d17e0f31e558d2", + "sha256:fe346b143ff9685e40192a4960938545c699054ba11d4f9029f94751e3f71d87" ], "markers": "python_version >= '3.8' and python_full_version not in '3.9.0, 3.9.1'", - "version": "==46.0.3" + "version": "==46.0.5" }, "dataclasses-json": { "hashes": [ @@ -1102,11 +1111,11 @@ }, "dill": { "hashes": [ - "sha256:0633f1d2df477324f53a895b02c901fb961bdbf65a17122586ea7019292cbcf0", - "sha256:44f54bf6412c2c8464c14e8243eb163690a9800dbe2c367330883b19c7561049" + "sha256:1e1ce33e978ae97fcfcff5638477032b801c46c7c65cf717f95fbc2248f79a9d", + "sha256:423092df4182177d4d8ba8290c8a5b640c66ab35ec7da59ccfa00f6fa3eea5fa" ], - "markers": "python_version >= '3.8'", - "version": "==0.4.0" + "markers": "python_version >= '3.9'", + "version": "==0.4.1" }, "distlib": { "hashes": [ @@ -1125,11 +1134,11 @@ }, "filelock": { "hashes": [ - "sha256:a2241ff4ddde2a7cebddf78e39832509cb045d18ec1a09d7248d6bfc6bfbbe64", - "sha256:fbba7237d6ea277175a32c54bb71ef814a8546d8601269e1bfc388de333974e8" + "sha256:667d7dc0b7d1e1064dd5f8f8e80bdac157a6482e8d2e02cd16fd3b6b33bd6556", + "sha256:c22803117490f156e59fafce621f0550a7a853e2bbf4f87f112b11d469b6c81b" ], "markers": "python_version >= '3.10'", - "version": "==3.20.2" + "version": "==3.24.2" }, "flake8": { "hashes": [ @@ -1162,11 +1171,24 @@ }, "griffe": { "hashes": [ - "sha256:6f6762661949411031f5fcda9593f586e6ce8340f0ba88921a0f2ef7a81eb9a3", - "sha256:7726e3afd6f298fbc3696e67958803e7ac843c1cfe59734b6251a40cdbfb5eea" + "sha256:5418081135a391c3e6e757a7f3f156f1a1a746cc7b4023868ff7d5e2f9a980aa" + ], + "markers": "python_version >= '3.10'", + "version": "==2.0.0" + }, + "griffecli": { + "hashes": [ + "sha256:9f7cd9ee9b21d55e91689358978d2385ae65c22f307a63fb3269acf3f21e643d" ], "markers": "python_version >= '3.10'", - "version": "==1.15.0" + "version": "==2.0.0" + }, + "griffelib": { + "hashes": [ + "sha256:01284878c966508b6d6f1dbff9b6fa607bc062d8261c5c7253cb285b06422a7f" + ], + "markers": "python_version >= '3.10'", + "version": "==2.0.0" }, "h11": { "hashes": [ @@ -1194,11 +1216,11 @@ }, "identify": { "hashes": [ - "sha256:1181ef7608e00704db228516541eb83a88a9f94433a8c80bb9b5bd54b1d81757", - "sha256:e4f4864b96c6557ef2a1e1c951771838f4edc9df3a72ec7118b338801b11c7bf" + "sha256:391ee4d77741d994189522896270b787aed8670389bfd60f326d677d64a6dfb0", + "sha256:846857203b5511bbe94d5a352a48ef2359532bc8f6727b5544077a0dcfb24980" ], - "markers": "python_version >= '3.9'", - "version": "==2.6.15" + "markers": "python_version >= '3.10'", + "version": "==2.6.16" }, "idna": { "hashes": [ @@ -1242,11 +1264,11 @@ }, "jmespath": { "hashes": [ - "sha256:02e2e4cc71b5bcab88332eebf907519190dd9e6e82107fa7f83b1003a6252980", - "sha256:90261b206d6defd58fdd5e85f478bf633a2901798906be2ad389150c5c60edbe" + "sha256:472c87d80f36026ae83c6ddd0f1d05d4e510134ed462851fd5f754c8c3cbb88d", + "sha256:a5663118de4908c91729bea0acadca56526eb2698e83de10cd116ae0f4e97c64" ], - "markers": "python_version >= '3.7'", - "version": "==1.0.1" + "markers": "python_version >= '3.9'", + "version": "==1.1.0" }, "jsonschema": { "hashes": [ @@ -1266,11 +1288,11 @@ }, "markdown": { "hashes": [ - "sha256:37062d4f2aa4b2b6b32aefb80faa300f82cc790cb949a35b8caede34f2b68c0e", - "sha256:b5b99d6951e2e4948d939255596523444c0e677c669700b1d17aa4a8a464cb7c" + "sha256:994d51325d25ad8aa7ce4ebaec003febcce822c3f8c911e3b17c52f7f589f950", + "sha256:e91464b71ae3ee7afd3017d9f358ef0baf158fd9a298db92f1d4761133824c36" ], "markers": "python_version >= '3.10'", - "version": "==3.10" + "version": "==3.10.2" }, "markdown-include": { "hashes": [ @@ -1408,11 +1430,11 @@ }, "mkdocs-autorefs": { "hashes": [ - "sha256:469d85eb3114801d08e9cc55d102b3ba65917a869b893403b8987b601cf55dc9", - "sha256:beee715b254455c4aa93b6ef3c67579c399ca092259cc41b7d9342573ff1fc75" + "sha256:834ef5408d827071ad1bc69e0f39704fa34c7fc05bc8e1c72b227dfdc5c76089", + "sha256:d54a284f27a7346b9c38f1f852177940c222da508e66edc816a0fa55fc6da197" ], "markers": "python_version >= '3.9'", - "version": "==1.4.3" + "version": "==1.4.4" }, "mkdocs-get-deps": { "hashes": [ @@ -1448,19 +1470,19 @@ }, "mkdocstrings": { "hashes": [ - "sha256:351a006dbb27aefce241ade110d3cd040c1145b7a3eb5fd5ac23f03ed67f401a", - "sha256:4c50eb960bff6e05dfc631f6bc00dfabffbcb29c5ff25f676d64daae05ed82fa" + "sha256:0d66d18430c2201dc7fe85134277382baaa15e6b30979f3f3bdbabd6dbdb6046", + "sha256:ab670f55040722b49bb45865b2e93b824450fb4aef638b00d7acb493a9020434" ], "markers": "python_version >= '3.10'", - "version": "==1.0.0" + "version": "==1.0.3" }, "mkdocstrings-python": { "hashes": [ - "sha256:66ecff45c5f8b71bf174e11d49afc845c2dfc7fc0ab17a86b6b337e0f24d8d90", - "sha256:843a562221e6a471fefdd4b45cc6c22d2607ccbad632879234fa9692e9cf7732" + "sha256:31241c0f43d85a69306d704d5725786015510ea3f3c4bdfdb5a5731d83cdc2b0", + "sha256:4a32ccfc4b8d29639864698e81cfeb04137bce76bb9f3c251040f55d4b6e1ad8" ], "markers": "python_version >= '3.10'", - "version": "==2.0.1" + "version": "==2.0.2" }, "mypy-extensions": { "hashes": [ @@ -1496,81 +1518,81 @@ }, "numpy": { "hashes": [ - "sha256:009bd0ea12d3c784b6639a8457537016ce5172109e585338e11334f6a7bb88ee", - "sha256:02a2038eb27f9443a8b266a66911e926566b5a6ffd1a689b588f7f35b81e7dc3", - "sha256:0804a8e4ab070d1d35496e65ffd3cf8114c136a2b81f61dfab0de4b218aacfd5", - "sha256:0feafc9e03128074689183031181fac0897ff169692d8492066e949041096548", - "sha256:1889b3a3f47a7b5bee16bc25a2145bd7cb91897f815ce3499db64c7458b6d91d", - "sha256:1d8f9fde5f6dc1b6fc34df8162f3b3079365468703fee7f31d4e0cc8c63baed9", - "sha256:1ee32359fb7543b7b7bd0b2f46294db27e29e7bbdf70541e81b190836cd83ded", - "sha256:20c115517513831860c573996e395707aa9fb691eb179200125c250e895fcd93", - "sha256:21555da4ec4a0c942520ead42c3b0dc9477441e085c42b0fbdd6a084869a6f6b", - "sha256:23a3e9d1a6f360267e8fbb38ba5db355a6a7e9be71d7fce7ab3125e88bb646c8", - "sha256:2a8b6bb8369abefb8bd1801b054ad50e02b3275c8614dc6e5b0373c305291037", - "sha256:2e284ca13d5a8367e43734148622caf0b261b275673823593e3e3634a6490f83", - "sha256:2f585f52b2baf07ff3356158d9268ea095e221371f1074fadea2f42544d58b4d", - "sha256:316b2f2584682318539f0bcaca5a496ce9ca78c88066579ebd11fd06f8e4741e", - "sha256:32ed06d0fe9cae27d8fb5f400c63ccee72370599c75e683a6358dd3a4fb50aaf", - "sha256:355354388cba60f2132df297e2d53053d4063f79077b67b481d21276d61fc4df", - "sha256:35ddc8f4914466e6fc954c76527aa91aa763682a4f6d73249ef20b418fe6effb", - "sha256:36667db4d6c1cea79c8930ab72fadfb4060feb4bfe724141cd4bd064d2e5f8ce", - "sha256:36cbfb13c152b1c7c184ddac43765db8ad672567e7bafff2cc755a09917ed2e6", - "sha256:39699233bc72dd482da1415dcb06076e32f60eddc796a796c5fb6c5efce94667", - "sha256:39b19251dec4de8ff8496cd0806cbe27bf0684f765abb1f4809554de93785f2d", - "sha256:3d857f55e7fdf7c38ab96c4558c95b97d1c685be6b05c249f5fdafcbd6f9899e", - "sha256:3de19cfecd1465d0dcf8a5b5ea8b3155b42ed0b639dba4b71e323d74f2a3be5e", - "sha256:3f3096405acc48887458bbf9f6814d43785ac7ba2a57ea6442b581dedbc60ce6", - "sha256:40483b2f2d3ba7aad426443767ff5632ec3156ef09742b96913787d13c336471", - "sha256:413aa561266a4be2d06cd2b9665e89d9f54c543f418773076a76adcf2af08bc7", - "sha256:44aba2f0cafd287871a495fb3163408b0bd25bbce135c6f621534a07f4f7875c", - "sha256:49ff32b09f5aa0cd30a20c2b39db3e669c845589f2b7fc910365210887e39344", - "sha256:4d1cfce39e511069b11e67cd0bd78ceff31443b7c9e5c04db73c7a19f572967c", - "sha256:57c540ed8fb1f05cb997c6761cd56db72395b0d6985e90571ff660452ade4f98", - "sha256:5f48cb3e88fbc294dc90e215d86fbaf1c852c63dbdb6c3a3e63f45c4b57f7344", - "sha256:5fe44e277225fd3dff6882d86d3d447205d43532c3627313d17e754fb3905a0e", - "sha256:60e8c196cd82cbbd4f130b5290007e13e6de3eca79f0d4d38014769d96a7c475", - "sha256:65167da969cd1ec3a1df31cb221ca3a19a8aaa25370ecb17d428415e93c1935e", - "sha256:680060061adb2d74ce352628cb798cfdec399068aa7f07ba9fb818b2b3305f98", - "sha256:6bbaebf0d11567fa8926215ae731e1d58e6ec28a8a25235b8a47405d301332db", - "sha256:6c05483c3136ac4c91b4e81903cb53a8707d316f488124d0398499a4f8e8ef51", - "sha256:6dc8b7e2f4eb184b37655195f421836cfae6f58197b67e3ffc501f1333d993fa", - "sha256:6e504f7b16118198f138ef31ba24d985b124c2c469fe8467007cf30fd992f934", - "sha256:74623f2ab5cc3f7c886add4f735d1031a1d2be4a4ae63c0546cfd74e7a31ddf6", - "sha256:77f0d13fa87036d7553bf81f0e1fe3ce68d14c9976c9851744e4d3e91127e95f", - "sha256:85eef4cb5625c47ee6425c58a3502555e10f45ee973da878ac8248ad58c136f3", - "sha256:882567b7ae57c1b1a0250208cc21a7976d8cbcc49d5a322e607e6f09c9e0bd53", - "sha256:8b986403023c8f3bf8f487c2e6186afda156174d31c175f747d8934dfddf3479", - "sha256:8cfa5f29a695cb7438965e6c3e8d06e0416060cf0d709c1b1c1653a939bf5c2a", - "sha256:8eafe7c36c8430b7794edeab3087dec7bf31d634d92f2af9949434b9d1964cba", - "sha256:9198f447e1dc5647d07c9a6bbe2063cc0132728cc7175b39dbc796da5b54920d", - "sha256:92190db305a6f48734d3982f2c60fa30d6b5ee9bff10f2887b930d7b40119f4c", - "sha256:927ccf5cd17c48f801f4ed43a7e5673a2724bd2171460be3e3894e6e332ef83a", - "sha256:98e81648e0b36e325ab67e46b5400a7a6d4a22b8a7c8e8bbfe20e7db7906bf95", - "sha256:9a818668b674047fd88c4cddada7ab8f1c298812783e8328e956b78dc4807f9f", - "sha256:a152d86a3ae00ba5f47b3acf3b827509fd0b6cb7d3259665e63dafbad22a75ea", - "sha256:a2718c1de8504121714234b6f8241d0019450353276c88b9453c9c3d92e101db", - "sha256:a39fb973a726e63223287adc6dafe444ce75af952d711e400f3bf2b36ef55a7b", - "sha256:a899699294f28f7be8992853c0c60741f16ff199205e2e6cdca155762cbaa59d", - "sha256:a8fdfed3deaf1928fb7667d96e0567cdf58c2b370ea2ee7e586aa383ec2cb346", - "sha256:aabb081ca0ec5d39591fc33018cd4b3f96e1a2dd6756282029986d00a785fba4", - "sha256:b1f5b45829ac1848893f0ddf5cb326110604d6df96cdc255b0bf9edd154104d4", - "sha256:b48e35f4ab6f6a7597c46e301126ceba4c44cd3280e3750f85db48b082624fa4", - "sha256:b54c83f1c0c0f1d748dca0af516062b8829d53d1f0c402be24b4257a9c48ada6", - "sha256:ba0cb30acd3ef11c94dc27fbfba68940652492bc107075e7ffe23057f9425681", - "sha256:bb50ce5fb202a26fd5404620e7ef820ad1ab3558b444cb0b55beb7ef66cd2d63", - "sha256:c95eb6db2884917d86cde0b4d4cf31adf485c8ec36bf8696dd66fa70de96f36b", - "sha256:d0ffd9e2e4441c96a9c91ec1783285d80bf835b677853fc2770a89d50c1e48ac", - "sha256:d57b5046c120561ba8fa8e4030fbb8b822f3063910fa901ffadf16e2b7128ad6", - "sha256:d9e6a7664ddd9746e20b7325351fe1a8408d0a2bf9c63b5e898290ddc8f09544", - "sha256:dc578891de1db95b2a35001b695451767b580bb45753717498213c5ff3c41d63", - "sha256:e0434aa22c821f44eeb4c650b81c7fbdd8c0122c6c4b5a576a76d5a35625ecd9", - "sha256:e06a922a469cae9a57100864caf4f8a97a1026513793969f8ba5b63137a35d25", - "sha256:e493962256a38f58283de033d8af176c5c91c084ea30f15834f7545451c42059", - "sha256:ecb0019d44f4cdb50b676c5d0cb4b1eae8e15d1ed3d3e6639f986fc92b2ec52c", - "sha256:f935c4493eda9069851058fa0d9e39dbf6286be690066509305e52912714dbb2" + "sha256:00ab83c56211a1d7c07c25e3217ea6695e50a3e2f255053686b081dc0b091a82", + "sha256:068cdb2d0d644cdb45670810894f6a0600797a69c05f1ac478e8d31670b8ee75", + "sha256:0f01dcf33e73d80bd8dc0f20a71303abbafa26a19e23f6b68d1aa9990af90257", + "sha256:0fece1d1f0a89c16b03442eae5c56dc0be0c7883b5d388e0c03f53019a4bfd71", + "sha256:12e26134a0331d8dbd9351620f037ec470b7c75929cb8a1537f6bfe411152a1a", + "sha256:1ae241bbfc6ae276f94a170b14785e561cb5e7f626b6688cf076af4110887413", + "sha256:1f92f53998a17265194018d1cc321b2e96e900ca52d54c7c77837b71b9465181", + "sha256:209fae046e62d0ce6435fcfe3b1a10537e858249b3d9b05829e2a05218296a85", + "sha256:20abd069b9cda45874498b245c8015b18ace6de8546bf50dfa8cea1696ed06ef", + "sha256:21982668592194c609de53ba4933a7471880ccbaadcc52352694a59ecc860b3a", + "sha256:25f2059807faea4b077a2b6837391b5d830864b3543627f381821c646f31a63c", + "sha256:2653de5c24910e49c2b106499803124dde62a5a1fe0eedeaecf4309a5f639390", + "sha256:2b8f157c8a6f20eb657e240f8985cc135598b2b46985c5bccbde7616dc9c6b1e", + "sha256:2fb882da679409066b4603579619341c6d6898fc83a8995199d5249f986e8e8f", + "sha256:40397bda92382fcec844066efb11f13e1c9a3e2a8e8f318fb72ed8b6db9f60f1", + "sha256:444be170853f1f9d528428eceb55f12918e4fda5d8805480f36a002f1415e09b", + "sha256:47c5a6ed21d9452b10227e5e8a0e1c22979811cad7dcc19d8e3e2fb8fa03f1a3", + "sha256:4f069069931240b3fc703f1e23df63443dbd6390614c8c44a87d96cd0ec81eb1", + "sha256:52b913ec40ff7ae845687b0b34d8d93b60cb66dcee06996dd5c99f2fc9328657", + "sha256:5633c0da313330fd20c484c78cdd3f9b175b55e1a766c4a174230c6b70ad8262", + "sha256:5daf6f3914a733336dab21a05cdec343144600e964d2fcdabaac0c0269874b2a", + "sha256:5eea80d908b2c1f91486eb95b3fb6fab187e569ec9752ab7d9333d2e66bf2d6b", + "sha256:602f65afdef699cda27ec0b9224ae5dc43e328f4c24c689deaf77133dbee74d0", + "sha256:659a6107e31a83c4e33f763942275fd278b21d095094044eb35569e86a21ddae", + "sha256:66cb9422236317f9d44b67b4d18f44efe6e9c7f8794ac0462978513359461554", + "sha256:6d82351358ffbcdcd7b686b90742a9b86632d6c1c051016484fa0b326a0a1548", + "sha256:6e9f61981ace1360e42737e2bae58b27bf28a1b27e781721047d84bd754d32e7", + "sha256:6ed0be1ee58eef41231a5c943d7d1375f093142702d5723ca2eb07db9b934b05", + "sha256:7cdde6de52fb6664b00b056341265441192d1291c130e99183ec0d4b110ff8b1", + "sha256:7df2de1e4fba69a51c06c28f5a3de36731eb9639feb8e1cf7e4a7b0daf4cf622", + "sha256:7edc794af8b36ca37ef5fcb5e0d128c7e0595c7b96a2318d1badb6fcd8ee86b1", + "sha256:7f54844851cdb630ceb623dcec4db3240d1ac13d4990532446761baede94996a", + "sha256:805cc8de9fd6e7a22da5aed858e0ab16be5a4db6c873dde1d7451c541553aa27", + "sha256:8906e71fd8afcb76580404e2a950caef2685df3d2a57fe82a86ac8d33cc007ba", + "sha256:89f7268c009bc492f506abd6f5265defa7cb3f7487dc21d357c3d290add45082", + "sha256:8c50dd1fc8826f5b26a5ee4d77ca55d88a895f4e4819c7ecc2a9f5905047a443", + "sha256:8e4549f8a3c6d13d55041925e912bfd834285ef1dd64d6bc7d542583355e2e98", + "sha256:8e9afaeb0beff068b4d9cd20d322ba0ee1cecfb0b08db145e4ab4dd44a6b5110", + "sha256:98f16a80e917003a12c0580f97b5f875853ebc33e2eaa4bccfc8201ac6869308", + "sha256:9e35d3e0144137d9fdae62912e869136164534d64a169f86438bc9561b6ad49f", + "sha256:9e4424677ce4b47fe73c8b5556d876571f7c6945d264201180db2dc34f676ab5", + "sha256:adb6ed2ad29b9e15321d167d152ee909ec73395901b70936f029c3bc6d7f4460", + "sha256:aea4f66ff44dfddf8c2cffd66ba6538c5ec67d389285292fe428cb2c738c8aef", + "sha256:b21041e8cb6a1eb5312dd1d2f80a94d91efffb7a06b70597d44f1bd2dfc315ab", + "sha256:b2f0073ed0868db1dcd86e052d37279eef185b9c8db5bf61f30f46adac63c909", + "sha256:b3a24467af63c67829bfaa61eecf18d5432d4f11992688537be59ecd6ad32f5e", + "sha256:b9c618d56a29c9cb1c4da979e9899be7578d2e0b3c24d52079c166324c9e8695", + "sha256:bba37bc29d4d85761deed3954a1bc62be7cf462b9510b51d367b769a8c8df325", + "sha256:bd3a7a9f5847d2fb8c2c6d1c862fa109c31a9abeca1a3c2bd5a64572955b2979", + "sha256:be71bf1edb48ebbbf7f6337b5bfd2f895d1902f6335a5830b20141fc126ffba0", + "sha256:c02ef4401a506fb60b411467ad501e1429a3487abca4664871d9ae0b46c8ba32", + "sha256:c3cd545784805de05aafe1dde61752ea49a359ccba9760c1e5d1c88a93bbf2b7", + "sha256:c7ac672d699bf36275c035e16b65539931347d68b70667d28984c9fb34e07fa7", + "sha256:cb7bbb88aa74908950d979eeaa24dbdf1a865e3c7e45ff0121d8f70387b55f73", + "sha256:cd2bd2bbed13e213d6b55dc1d035a4f91748a7d3edc9480c13898b0353708920", + "sha256:cda077c2e5b780200b6b3e09d0b42205a3d1c68f30c6dceb90401c13bff8fe74", + "sha256:cf28c0c1d4c4bf00f509fa7eb02c58d7caf221b50b467bcb0d9bbf1584d5c821", + "sha256:d0d9b7c93578baafcbc5f0b83eaf17b79d345c6f36917ba0c67f45226911d499", + "sha256:d1240d50adff70c2a88217698ca844723068533f3f5c5fa6ee2e3220e3bdb000", + "sha256:d30291931c915b2ab5717c2974bb95ee891a1cf22ebc16a8006bd59cd210d40a", + "sha256:d9f64d786b3b1dd742c946c42d15b07497ed14af1a1f3ce840cce27daa0ce913", + "sha256:da6cad4e82cb893db4b69105c604d805e0c3ce11501a55b5e9f9083b47d2ffe8", + "sha256:df1b10187212b198dd45fa943d8985a3c8cf854aed4923796e0e019e113a1bda", + "sha256:e04ae107ac591763a47398bb45b568fc38f02dbc4aa44c063f67a131f99346cb", + "sha256:e6dee3bb76aa4009d5a912180bf5b2de012532998d094acee25d9cb8dee3e44a", + "sha256:e7e88598032542bd49af7c4747541422884219056c268823ef6e5e89851c8825", + "sha256:e98c97502435b53741540a5717a6749ac2ada901056c7db951d33e11c885cc7d", + "sha256:ec055f6dae239a6299cace477b479cca2fc125c5675482daf1dd886933a1076f", + "sha256:f74f0f7779cc7ae07d1810aab8ac6b1464c3eafb9e283a40da7309d5e6e48fbb", + "sha256:fbde1b0c6e81d56f5dccd95dd4a711d9b95df1ae4009a60887e56b27e8d903fa", + "sha256:fcf92bee92742edd401ba41135185866f7026c502617f422eb432cfeca4fe236", + "sha256:fd49860271d52127d61197bb50b64f58454e9f578cb4b2c001a6de8b1f50b0b1" ], "markers": "python_version >= '3.11'", - "version": "==2.4.0" + "version": "==2.4.2" }, "opentelemetry-api": { "hashes": [ @@ -1670,11 +1692,11 @@ }, "packaging": { "hashes": [ - "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484", - "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f" + "sha256:00243ae351a257117b6a241061796684b084ed1c516a08c48a3f7e147a9d80b4", + "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529" ], "markers": "python_version >= '3.8'", - "version": "==25.0" + "version": "==26.0" }, "paginate": { "hashes": [ @@ -1761,19 +1783,19 @@ }, "pathspec": { "hashes": [ - "sha256:62f8558917908d237d399b9b338ef455a814801a4688bc41074b25feefd93472", - "sha256:fa32b1eb775ed9ba8d599b22c5f906dc098113989da2c00bf8b210078ca7fb92" + "sha256:0210e2ae8a21a9137c0d470578cb0e595af87edaa6ebf12ff176f14a02e0e645", + "sha256:fb6ae2fd4e7c921a165808a552060e722767cfa526f99ca5156ed2ce45a5c723" ], "markers": "python_version >= '3.9'", - "version": "==1.0.2" + "version": "==1.0.4" }, "platformdirs": { "hashes": [ - "sha256:61d5cdcc6065745cdd94f0f878977f8de9437be93de97c1c12f853c9c0cdcbda", - "sha256:d03afa3963c806a9bed9d5125c8f4cb2fdaf74a55ab60e5d59b3fde758104d31" + "sha256:9170634f126f8efdae22fb58ae8a0eaa86f38365bc57897a6c4f781d1f5875bd", + "sha256:9a33809944b9db043ad67ca0db94b14bf452cc6aeaac46a88ea55b26e2e9d291" ], "markers": "python_version >= '3.10'", - "version": "==4.5.1" + "version": "==4.9.2" }, "pluggy": { "hashes": [ @@ -1793,46 +1815,46 @@ }, "protobuf": { "hashes": [ - "sha256:1f8017c48c07ec5859106533b682260ba3d7c5567b1ca1f24297ce03384d1b4f", - "sha256:2981c58f582f44b6b13173e12bb8656711189c2a70250845f264b877f00b1913", - "sha256:56dc370c91fbb8ac85bc13582c9e373569668a290aa2e66a590c2a0d35ddb9e4", - "sha256:7109dcc38a680d033ffb8bf896727423528db9163be1b6a02d6a49606dcadbfe", - "sha256:7636aad9bb01768870266de5dc009de2d1b936771b38a793f73cbbf279c91c5c", - "sha256:87eb388bd2d0f78febd8f4c8779c79247b26a5befad525008e49a6955787ff3d", - "sha256:8cd7640aee0b7828b6d03ae518b5b4806fdfc1afe8de82f79c3454f8aef29872", - "sha256:b5d3b5625192214066d99b2b605f5783483575656784de223f00a8d00754fc0e", - "sha256:d9b19771ca75935b3a4422957bc518b0cecb978b31d1dd12037b088f6bcc0e43", - "sha256:fc2a0e8b05b180e5fc0dd1559fe8ebdae21a27e81ac77728fb6c42b12c7419b4" + "sha256:3093804752167bcab3998bec9f1048baae6e29505adaf1afd14a37bddede533c", + "sha256:69915a973dd0f60f31a08b8318b73eab2bd6a392c79184b3612226b0a3f8ec02", + "sha256:6ddcac2a081f8b7b9642c09406bc6a4290128fce5f471cddd165960bb9119e5c", + "sha256:8afa18e1d6d20af15b417e728e9f60f3aa108ee76f23c3b2c07a2c3b546d3afd", + "sha256:8f04fa32763dcdb4973d537d6b54e615cc61108c7cb38fe59310c3192d29510a", + "sha256:9b71e0281f36f179d00cbcb119cb19dec4d14a81393e5ea220f64b286173e190", + "sha256:a3157e62729aafb8df6da2c03aa5c0937c7266c626ce11a278b6eb7963c4e37c", + "sha256:a5cb85982d95d906df1e2210e58f8e4f1e3cdc088e52c921a041f9c9a0386de5", + "sha256:cbf16ba3350fb7b889fca858fb215967792dc125b35c7976ca4818bee3521cf0", + "sha256:d71b040839446bac0f4d162e758bea99c8251161dae9d0983a3b88dee345153b" ], "markers": "python_version >= '3.9'", - "version": "==6.33.2" + "version": "==6.33.5" }, "psutil": { "hashes": [ - "sha256:05cc68dbb8c174828624062e73078e7e35406f4ca2d0866c272c2410d8ef06d1", - "sha256:08a2f175e48a898c8eb8eace45ce01777f4785bc744c90aa2cc7f2fa5462a266", - "sha256:0d67c1822c355aa6f7314d92018fb4268a76668a536f133599b91edd48759442", - "sha256:2ceae842a78d1603753561132d5ad1b2f8a7979cb0c283f5b52fb4e6e14b1a79", - "sha256:35630d5af80d5d0d49cfc4d64c1c13838baf6717a13effb35869a5919b854cdf", - "sha256:3fce5f92c22b00cdefd1645aa58ab4877a01679e901555067b1bd77039aa589f", - "sha256:494c513ccc53225ae23eec7fe6e1482f1b8a44674241b54561f755a898650679", - "sha256:5e38404ca2bb30ed7267a46c02f06ff842e92da3bb8c5bfdadbd35a5722314d8", - "sha256:81442dac7abfc2f4f4385ea9e12ddf5a796721c0f6133260687fec5c3780fa49", - "sha256:923f8653416604e356073e6e0bccbe7c09990acef442def2f5640dd0faa9689f", - "sha256:93f3f7b0bb07711b49626e7940d6fe52aa9940ad86e8f7e74842e73189712129", - "sha256:99a4cd17a5fdd1f3d014396502daa70b5ec21bf4ffe38393e152f8e449757d67", - "sha256:ab2b98c9fc19f13f59628d94df5cc4cc4844bc572467d113a8b517d634e362c6", - "sha256:b1b0671619343aa71c20ff9767eced0483e4fc9e1f489d50923738caf6a03c17", - "sha256:b2e953fcfaedcfbc952b44744f22d16575d3aa78eb4f51ae74165b4e96e55f42", - "sha256:ba9f33bb525b14c3ea563b2fd521a84d2fa214ec59e3e6a2858f78d0844dd60d", - "sha256:cfbe6b40ca48019a51827f20d830887b3107a74a79b01ceb8cc8de4ccb17b672", - "sha256:d34d2ca888208eea2b5c68186841336a7f5e0b990edec929be909353a202768a", - "sha256:ea46c0d060491051d39f0d2cff4f98d5c72b288289f57a21556cc7d504db37fc", - "sha256:f7583aec590485b43ca601dd9cea0dcd65bd7bb21d30ef4ddbf4ea6b5ed1bdd3", - "sha256:f78baafb38436d5a128f837fab2d92c276dfb48af01a240b861ae02b2413ada8" + "sha256:0746f5f8d406af344fd547f1c8daa5f5c33dbc293bb8d6a16d80b4bb88f59372", + "sha256:076a2d2f923fd4821644f5ba89f059523da90dc9014e85f8e45a5774ca5bc6f9", + "sha256:11fe5a4f613759764e79c65cf11ebdf26e33d6dd34336f8a337aa2996d71c841", + "sha256:1a571f2330c966c62aeda00dd24620425d4b0cc86881c89861fbc04549e5dc63", + "sha256:1a7b04c10f32cc88ab39cbf606e117fd74721c831c98a27dc04578deb0c16979", + "sha256:1fa4ecf83bcdf6e6c8f4449aff98eefb5d0604bf88cb883d7da3d8d2d909546a", + "sha256:2edccc433cbfa046b980b0df0171cd25bcaeb3a68fe9022db0979e7aa74a826b", + "sha256:7b6d09433a10592ce39b13d7be5a54fbac1d1228ed29abc880fb23df7cb694c9", + "sha256:8c233660f575a5a89e6d4cb65d9f938126312bca76d8fe087b947b3a1aaac9ee", + "sha256:917e891983ca3c1887b4ef36447b1e0873e70c933afc831c6b6da078ba474312", + "sha256:ab486563df44c17f5173621c7b198955bd6b613fb87c71c161f827d3fb149a9b", + "sha256:ae0aefdd8796a7737eccea863f80f81e468a1e4cf14d926bd9b6f5f2d5f90ca9", + "sha256:b0726cecd84f9474419d67252add4ac0cd9811b04d61123054b9fb6f57df6e9e", + "sha256:b58fabe35e80b264a4e3bb23e6b96f9e45a3df7fb7eed419ac0e5947c61e47cc", + "sha256:c7663d4e37f13e884d13994247449e9f8f574bc4655d509c3b95e9ec9e2b9dc1", + "sha256:e452c464a02e7dc7822a05d25db4cde564444a67e58539a00f929c51eddda0cf", + "sha256:e78c8603dcd9a04c7364f1a3e670cea95d51ee865e4efb3556a3a63adef958ea", + "sha256:eb7e81434c8d223ec4a219b5fc1c47d0417b12be7ea866e24fb5ad6e84b3d988", + "sha256:ed0cace939114f62738d808fdcecd4c869222507e266e574799e9c0faa17d486", + "sha256:eed63d3b4d62449571547b60578c5b2c4bcccc5387148db46e0c2313dad0ee00", + "sha256:fd04ef36b4a6d599bbdb225dd1d3f51e00105f6d48a28f006da7f9822f2606d8" ], "markers": "python_version >= '3.6'", - "version": "==7.2.1" + "version": "==7.2.2" }, "py": { "hashes": [ @@ -1852,11 +1874,11 @@ }, "pycparser": { "hashes": [ - "sha256:78816d4f24add8f10a06d6f05b4d424ad9e96cfebf68a4ddc99c65c0720d00c2", - "sha256:e5c6e8d3fbad53479cab09ac03729e0a9faf2bee3db8208a550daf5af81a5934" + "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29", + "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992" ], - "markers": "python_version >= '3.8'", - "version": "==2.23" + "markers": "python_version >= '3.10'", + "version": "==3.0" }, "pyflakes": { "hashes": [ @@ -1876,11 +1898,11 @@ }, "pymdown-extensions": { "hashes": [ - "sha256:5c73566ab0cf38c6ba084cb7c5ea64a119ae0500cce754ccb682761dfea13a52", - "sha256:ea9e62add865da80a271d00bfa1c0fa085b20d133fb3fc97afdc88e682f60b2f" + "sha256:39f4a020f40773f6b2ff31d2cd2546c2c04d0a6498c31d9c688d2be07e1767d5", + "sha256:91b879f9f864d49794c2d9534372b10150e6141096c3908a455e45ca72ad9d3f" ], "markers": "python_version >= '3.9'", - "version": "==10.20" + "version": "==10.21" }, "pynacl": { "hashes": [ @@ -1915,11 +1937,11 @@ }, "pyparsing": { "hashes": [ - "sha256:023b5e7e5520ad96642e2c6db4cb683d3970bd640cdf7115049a6e9c3682df82", - "sha256:47fad0f17ac1e2cad3de3b458570fbc9b03560aa029ed5e16ee5554da9a2251c" + "sha256:850ba148bd908d7e2411587e247a1e4f0327839c40e2e5e6d05a007ecc69911d", + "sha256:c777f4d763f140633dcb6d8a3eda953bf7a214dc4eff598413c070bcdc117cbc" ], "markers": "python_version >= '3.9'", - "version": "==3.3.1" + "version": "==3.3.2" }, "pysftp": { "hashes": [ @@ -2004,11 +2026,51 @@ }, "pytokens": { "hashes": [ - "sha256:2f932b14ed08de5fcf0b391ace2642f858f1394c0857202959000b68ed7a458a", - "sha256:95b2b5eaf832e469d141a378872480ede3f251a5a5041b8ec6e581d3ac71bbf3" + "sha256:0fc71786e629cef478cbf29d7ea1923299181d0699dbe7c3c0f4a583811d9fc1", + "sha256:11edda0942da80ff58c4408407616a310adecae1ddd22eef8c692fe266fa5009", + "sha256:140709331e846b728475786df8aeb27d24f48cbcf7bcd449f8de75cae7a45083", + "sha256:24afde1f53d95348b5a0eb19488661147285ca4dd7ed752bbc3e1c6242a304d1", + "sha256:26cef14744a8385f35d0e095dc8b3a7583f6c953c2e3d269c7f82484bf5ad2de", + "sha256:27b83ad28825978742beef057bfe406ad6ed524b2d28c252c5de7b4a6dd48fa2", + "sha256:292052fe80923aae2260c073f822ceba21f3872ced9a68bb7953b348e561179a", + "sha256:29d1d8fb1030af4d231789959f21821ab6325e463f0503a61d204343c9b355d1", + "sha256:2a44ed93ea23415c54f3face3b65ef2b844d96aeb3455b8a69b3df6beab6acc5", + "sha256:30f51edd9bb7f85c748979384165601d028b84f7bd13fe14d3e065304093916a", + "sha256:34bcc734bd2f2d5fe3b34e7b3c0116bfb2397f2d9666139988e7a3eb5f7400e3", + "sha256:3ad72b851e781478366288743198101e5eb34a414f1d5627cdd585ca3b25f1db", + "sha256:3f901fe783e06e48e8cbdc82d631fca8f118333798193e026a50ce1b3757ea68", + "sha256:42f144f3aafa5d92bad964d471a581651e28b24434d184871bd02e3a0d956037", + "sha256:4a14d5f5fc78ce85e426aa159489e2d5961acf0e47575e08f35584009178e321", + "sha256:4a58d057208cb9075c144950d789511220b07636dd2e4708d5645d24de666bdc", + "sha256:4e691d7f5186bd2842c14813f79f8884bb03f5995f0575272009982c5ac6c0f7", + "sha256:5502408cab1cb18e128570f8d598981c68a50d0cbd7c61312a90507cd3a1276f", + "sha256:584c80c24b078eec1e227079d56dc22ff755e0ba8654d8383b2c549107528918", + "sha256:5ad948d085ed6c16413eb5fec6b3e02fa00dc29a2534f088d3302c47eb59adf9", + "sha256:670d286910b531c7b7e3c0b453fd8156f250adb140146d234a82219459b9640c", + "sha256:682fa37ff4d8e95f7df6fe6fe6a431e8ed8e788023c6bcc0f0880a12eab80ad1", + "sha256:6d6c4268598f762bc8e91f5dbf2ab2f61f7b95bdc07953b602db879b3c8c18e1", + "sha256:79fc6b8699564e1f9b521582c35435f1bd32dd06822322ec44afdeba666d8cb3", + "sha256:8bdb9d0ce90cbf99c525e75a2fa415144fd570a1ba987380190e8b786bc6ef9b", + "sha256:8fcb9ba3709ff77e77f1c7022ff11d13553f3c30299a9fe246a166903e9091eb", + "sha256:941d4343bf27b605e9213b26bfa1c4bf197c9c599a9627eb7305b0defcfe40c1", + "sha256:967cf6e3fd4adf7de8fc73cd3043754ae79c36475c1c11d514fc72cf5490094a", + "sha256:970b08dd6b86058b6dc07efe9e98414f5102974716232d10f32ff39701e841c4", + "sha256:97f50fd18543be72da51dd505e2ed20d2228c74e0464e4262e4899797803d7fa", + "sha256:9bd7d7f544d362576be74f9d5901a22f317efc20046efe2034dced238cbbfe78", + "sha256:add8bf86b71a5d9fb5b89f023a80b791e04fba57960aa790cc6125f7f1d39dfe", + "sha256:b35d7e5ad269804f6697727702da3c517bb8a5228afa450ab0fa787732055fc9", + "sha256:b49750419d300e2b5a3813cf229d4e5a4c728dae470bcc89867a9ad6f25a722d", + "sha256:d31b97b3de0f61571a124a00ffe9a81fb9939146c122c11060725bd5aea79975", + "sha256:d70e77c55ae8380c91c0c18dea05951482e263982911fc7410b1ffd1dadd3440", + "sha256:d9907d61f15bf7261d7e775bd5d7ee4d2930e04424bab1972591918497623a16", + "sha256:da5baeaf7116dced9c6bb76dc31ba04a2dc3695f3d9f74741d7910122b456edc", + "sha256:dc74c035f9bfca0255c1af77ddd2d6ae8419012805453e4b0e7513e17904545d", + "sha256:dcafc12c30dbaf1e2af0490978352e0c4041a7cde31f4f81435c2a5e8b9cabb6", + "sha256:ee44d0f85b803321710f9239f335aafe16553b39106384cef8e6de40cb4ef2f6", + "sha256:f66a6bbe741bd431f6d741e617e0f39ec7257ca1f89089593479347cc4d13324" ], "markers": "python_version >= '3.8'", - "version": "==0.3.0" + "version": "==0.4.1" }, "pytz": { "hashes": [ @@ -2106,11 +2168,11 @@ }, "rdflib": { "hashes": [ - "sha256:663083443908b1830e567350d72e74d9948b310f827966358d76eebdc92bf592", - "sha256:b011dfc40d0fc8a44252e906dcd8fc806a7859bc231be190c37e9568a31ac572" + "sha256:30c0a3ebf4c0e09215f066be7246794b6492e054e782d7ac2a34c9f70a15e0dd", + "sha256:6c831288d5e4a5a7ece85d0ccde9877d512a3d0f02d7c06455d00d6d0ea379df" ], "markers": "python_full_version >= '3.8.1'", - "version": "==7.5.0" + "version": "==7.6.0" }, "referencing": { "hashes": [ @@ -2266,7 +2328,7 @@ "version": "==1.17.0" }, "synapseclient": { - "markers": "python_version < '3.14' and python_version >= '3.9'", + "markers": "python_version >= '3.10' and python_version < '3.15'", "path": "." }, "termynal": { @@ -2279,11 +2341,11 @@ }, "tqdm": { "hashes": [ - "sha256:26445eca388f82e72884e0d580d5464cd801a3ea01e63e5601bdff9ba6a48de2", - "sha256:f8aef9c52c08c13a65f30ea34f4e5aac3fd1a34959879d7e59e63027286627f2" + "sha256:7d825f03f89244ef73f1d4ce193cb1774a8179fd96f31d7e1dcde62092b960bb", + "sha256:ee1e4c0e59148062281c49d80b25b67771a127c85fc9676d3be5f243206826bf" ], "markers": "python_version >= '3.7'", - "version": "==4.67.1" + "version": "==4.67.3" }, "typing-extensions": { "hashes": [ @@ -2318,11 +2380,11 @@ }, "virtualenv": { "hashes": [ - "sha256:a3601f540b515a7983508113f14e78993841adc3d83710fa70f0ac50f43b23ed", - "sha256:e7ded577f3af534fd0886d4ca03277f5542053bedb98a70a989d3c22cfa5c9ac" + "sha256:5d3951c32d57232ae3569d4de4cc256c439e045135ebf43518131175d9be435d", + "sha256:6f7e2064ed470aa7418874e70b6369d53b66bcd9e9fd5389763e96b6c94ccb7c" ], "markers": "python_version >= '3.8'", - "version": "==20.36.0" + "version": "==20.37.0" }, "watchdog": { "hashes": [ diff --git a/setup.cfg b/setup.cfg index 8a22c3407..bc41c1213 100644 --- a/setup.cfg +++ b/setup.cfg @@ -86,7 +86,7 @@ dev = pytest-cov~=4.1.0 black pre-commit - filelock>=3.20.1 + filelock>=3.20.3 pandas>=1.5,<3.0 tests = From 05910c1bbb38a6cc5b808f23be15e55d7a99af4a Mon Sep 17 00:00:00 2001 From: BryanFauble <17128019+BryanFauble@users.noreply.github.com> Date: Mon, 16 Feb 2026 21:39:14 +0000 Subject: [PATCH 2/6] Bump cache version --- .github/workflows/build.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 3e56450a7..d7490657c 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -83,7 +83,7 @@ jobs: path: | ${{ steps.get-dependencies.outputs.site_packages_loc }} ${{ steps.get-dependencies.outputs.site_bin_dir }} - key: ${{ runner.os }}-${{ matrix.python }}-build-${{ env.cache-name }}-${{ hashFiles('setup.py') }}-v31 + key: ${{ runner.os }}-${{ matrix.python }}-build-${{ env.cache-name }}-${{ hashFiles('setup.py') }}-v32 - name: Install py-dependencies if: steps.cache-dependencies.outputs.cache-hit != 'true' From 771e4ca36b0ebbd260493ddb1b78118c8242eb6f Mon Sep 17 00:00:00 2001 From: BryanFauble <17128019+BryanFauble@users.noreply.github.com> Date: Tue, 17 Feb 2026 18:38:58 +0000 Subject: [PATCH 3/6] Refactor async test functions to synchronous in submission-related test files --- CONTRIBUTING.md | 7 +- .../models/async/test_recordset_async.py | 4 +- .../models/synchronous/test_recordset.py | 4 +- .../models/synchronous/test_submission.py | 72 +++++++++---------- .../synchronous/test_submission_bundle.py | 50 ++++++------- .../synchronous/test_submission_status.py | 72 +++++++++---------- 6 files changed, 102 insertions(+), 107 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 7efe88d6e..7b77e0586 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -428,8 +428,11 @@ April 2024 it was found that during a single run of all integration tests almost connections were created and subsequently closed during the test run. As such the following set of guidelines should be followed: -- All tests should use the `async` keyword. This allows any tests to share the -underlying HTTPX async client for requests. +- **Test function signatures:** + - Tests in `tests/integration/synapseclient/models/async/` should use `async def` to test async methods + - Tests in `tests/integration/synapseclient/models/synchronous/` should use regular `def` to test synchronous methods + - **Important (Python 3.14+):** Synchronous tests must NOT use `async def`, as this creates an active event loop that prevents synchronous methods from working correctly +- **Fixtures:** Can be `async def` if they need to call async methods, but should use regular `def` when calling synchronous methods - Any non `session` scoped fixtures should not execute an HTTP request. If the fixture does need to execute a request it should not be scoped to `function`. This is because each scope level runs it's own event loop; Connection pools cannot be shared between diff --git a/tests/integration/synapseclient/models/async/test_recordset_async.py b/tests/integration/synapseclient/models/async/test_recordset_async.py index 501018c25..4a3e58e20 100644 --- a/tests/integration/synapseclient/models/async/test_recordset_async.py +++ b/tests/integration/synapseclient/models/async/test_recordset_async.py @@ -666,10 +666,10 @@ async def test_get_validation_results_with_default_location_async( results_df.loc[4, "is_valid"] == False ), "Row 4 should be invalid (value below minimum)" # noqa: E712 assert ( - "-50 is not greater or equal to 0" + "-50.0 is not greater or equal to 0" in results_df.loc[4, "validation_error_message"] ), f"Row 4 should have minimum violation, got: {results_df.loc[4, 'validation_error_message']}" - assert "#/value: -50 is not greater or equal to 0" in str( + assert "#/value: -50.0 is not greater or equal to 0" in str( results_df.loc[4, "all_validation_messages"] ), f"Row 4 all_validation_messages incorrect: {results_df.loc[4, 'all_validation_messages']}" diff --git a/tests/integration/synapseclient/models/synchronous/test_recordset.py b/tests/integration/synapseclient/models/synchronous/test_recordset.py index 31336e9be..25bc9e2a3 100644 --- a/tests/integration/synapseclient/models/synchronous/test_recordset.py +++ b/tests/integration/synapseclient/models/synchronous/test_recordset.py @@ -659,10 +659,10 @@ def test_get_validation_results_with_default_location( results_df.loc[4, "is_valid"] == False ), "Row 4 should be invalid (value below minimum)" # noqa: E712 assert ( - "-50 is not greater or equal to 0" + "-50.0 is not greater or equal to 0" in results_df.loc[4, "validation_error_message"] ), f"Row 4 should have minimum violation, got: {results_df.loc[4, 'validation_error_message']}" - assert "#/value: -50 is not greater or equal to 0" in str( + assert "#/value: -50.0 is not greater or equal to 0" in str( results_df.loc[4, "all_validation_messages"] ), f"Row 4 all_validation_messages incorrect: {results_df.loc[4, 'all_validation_messages']}" diff --git a/tests/integration/synapseclient/models/synchronous/test_submission.py b/tests/integration/synapseclient/models/synchronous/test_submission.py index 3c5406270..a5ead72a7 100644 --- a/tests/integration/synapseclient/models/synchronous/test_submission.py +++ b/tests/integration/synapseclient/models/synchronous/test_submission.py @@ -17,7 +17,7 @@ def init(self, syn: Synapse, schedule_for_cleanup: Callable[..., None]) -> None: self.schedule_for_cleanup = schedule_for_cleanup @pytest.fixture(scope="function") - async def test_project( + def test_project( self, syn: Synapse, schedule_for_cleanup: Callable[..., None] ) -> Project: """Create a test project for submission tests.""" @@ -26,7 +26,7 @@ async def test_project( return project @pytest.fixture(scope="function") - async def test_evaluation( + def test_evaluation( self, test_project: Project, syn: Synapse, @@ -45,7 +45,7 @@ async def test_evaluation( return created_evaluation @pytest.fixture(scope="function") - async def test_file( + def test_file( self, test_project: Project, syn: Synapse, @@ -74,7 +74,7 @@ async def test_file( # Clean up the temporary file os.unlink(temp_file_path) - async def test_store_submission_successfully( + def test_store_submission_successfully( self, test_evaluation: Evaluation, test_file: File ): # WHEN I create a submission with valid data @@ -95,9 +95,7 @@ async def test_store_submission_successfully( assert created_submission.created_on is not None assert created_submission.version_number is not None - async def test_store_submission_without_entity_id( - self, test_evaluation: Evaluation - ): + def test_store_submission_without_entity_id(self, test_evaluation: Evaluation): # WHEN I try to create a submission without entity_id submission = Submission( evaluation_id=test_evaluation.id, @@ -108,7 +106,7 @@ async def test_store_submission_without_entity_id( with pytest.raises(ValueError, match="entity_id is required"): submission.store(synapse_client=self.syn) - async def test_store_submission_without_evaluation_id(self, test_file: File): + def test_store_submission_without_evaluation_id(self, test_file: File): # WHEN I try to create a submission without evaluation_id submission = Submission( entity_id=test_file.id, @@ -119,9 +117,7 @@ async def test_store_submission_without_evaluation_id(self, test_file: File): with pytest.raises(ValueError, match="missing the 'evaluation_id' attribute"): submission.store(synapse_client=self.syn) - async def test_store_submission_with_docker_repository( - self, test_evaluation: Evaluation - ): + def test_store_submission_with_docker_repository(self, test_evaluation: Evaluation): # GIVEN we would need a Docker repository entity (mocked for this test) # This test demonstrates the expected behavior for Docker repository submissions @@ -147,7 +143,7 @@ def init(self, syn: Synapse, schedule_for_cleanup: Callable[..., None]) -> None: self.schedule_for_cleanup = schedule_for_cleanup @pytest.fixture(scope="function") - async def test_project( + def test_project( self, syn: Synapse, schedule_for_cleanup: Callable[..., None] ) -> Project: """Create a test project for submission tests.""" @@ -156,7 +152,7 @@ async def test_project( return project @pytest.fixture(scope="function") - async def test_evaluation( + def test_evaluation( self, test_project: Project, syn: Synapse, @@ -175,7 +171,7 @@ async def test_evaluation( return created_evaluation @pytest.fixture(scope="function") - async def test_file( + def test_file( self, test_project: Project, syn: Synapse, @@ -203,7 +199,7 @@ async def test_file( os.unlink(temp_file_path) @pytest.fixture(scope="function") - async def test_submission( + def test_submission( self, test_evaluation: Evaluation, test_file: File, @@ -220,7 +216,7 @@ async def test_submission( schedule_for_cleanup(created_submission.id) return created_submission - async def test_get_submission_by_id( + def test_get_submission_by_id( self, test_submission: Submission, test_evaluation: Evaluation, test_file: File ): # WHEN I get a submission by ID @@ -236,7 +232,7 @@ async def test_get_submission_by_id( assert retrieved_submission.user_id is not None assert retrieved_submission.created_on is not None - async def test_get_evaluation_submissions( + def test_get_evaluation_submissions( self, test_evaluation: Evaluation, test_submission: Submission ): # WHEN I get all submissions for an evaluation @@ -254,7 +250,7 @@ async def test_get_evaluation_submissions( submission_ids = [sub.id for sub in submissions] assert test_submission.id in submission_ids - async def test_get_evaluation_submissions_generator_behavior( + def test_get_evaluation_submissions_generator_behavior( self, test_evaluation: Evaluation ): # WHEN I get submissions using the generator @@ -272,7 +268,7 @@ async def test_get_evaluation_submissions_generator_behavior( # AND all submissions should be valid Submission objects assert all(isinstance(sub, Submission) for sub in submissions) - async def test_get_user_submissions(self, test_evaluation: Evaluation): + def test_get_user_submissions(self, test_evaluation: Evaluation): # WHEN I get submissions for the current user submissions_generator = Submission.get_user_submissions( evaluation_id=test_evaluation.id, synapse_client=self.syn @@ -283,9 +279,7 @@ async def test_get_user_submissions(self, test_evaluation: Evaluation): # Note: Could be empty if user hasn't made submissions to this evaluation assert all(isinstance(sub, Submission) for sub in submissions) - async def test_get_user_submissions_generator_behavior( - self, test_evaluation: Evaluation - ): + def test_get_user_submissions_generator_behavior(self, test_evaluation: Evaluation): # WHEN I get user submissions using the generator submissions_generator = Submission.get_user_submissions( evaluation_id=test_evaluation.id, @@ -301,7 +295,7 @@ async def test_get_user_submissions_generator_behavior( # AND all submissions should be valid Submission objects assert all(isinstance(sub, Submission) for sub in submissions) - async def test_get_submission_count(self, test_evaluation: Evaluation): + def test_get_submission_count(self, test_evaluation: Evaluation): # WHEN I get the submission count for an evaluation response = Submission.get_submission_count( evaluation_id=test_evaluation.id, synapse_client=self.syn @@ -319,7 +313,7 @@ def init(self, syn: Synapse, schedule_for_cleanup: Callable[..., None]) -> None: self.schedule_for_cleanup = schedule_for_cleanup @pytest.fixture(scope="function") - async def test_project( + def test_project( self, syn: Synapse, schedule_for_cleanup: Callable[..., None] ) -> Project: """Create a test project for submission tests.""" @@ -328,7 +322,7 @@ async def test_project( return project @pytest.fixture(scope="function") - async def test_evaluation( + def test_evaluation( self, test_project: Project, syn: Synapse, @@ -347,7 +341,7 @@ async def test_evaluation( return created_evaluation @pytest.fixture(scope="function") - async def test_file( + def test_file( self, test_project: Project, syn: Synapse, @@ -374,7 +368,7 @@ async def test_file( finally: os.unlink(temp_file_path) - async def test_delete_submission_successfully( + def test_delete_submission_successfully( self, test_evaluation: Evaluation, test_file: File ): # GIVEN a submission @@ -392,7 +386,7 @@ async def test_delete_submission_successfully( with pytest.raises(SynapseHTTPError): Submission(id=created_submission.id).get(synapse_client=self.syn) - async def test_delete_submission_without_id(self): + def test_delete_submission_without_id(self): # WHEN I try to delete a submission without an ID submission = Submission(entity_id="syn123", evaluation_id="456") @@ -408,7 +402,7 @@ def init(self, syn: Synapse, schedule_for_cleanup: Callable[..., None]) -> None: self.schedule_for_cleanup = schedule_for_cleanup @pytest.fixture(scope="function") - async def test_project( + def test_project( self, syn: Synapse, schedule_for_cleanup: Callable[..., None] ) -> Project: """Create a test project for submission tests.""" @@ -417,7 +411,7 @@ async def test_project( return project @pytest.fixture(scope="function") - async def test_evaluation( + def test_evaluation( self, test_project: Project, syn: Synapse, @@ -436,7 +430,7 @@ async def test_evaluation( return created_evaluation @pytest.fixture(scope="function") - async def test_file( + def test_file( self, test_project: Project, syn: Synapse, @@ -463,7 +457,7 @@ async def test_file( finally: os.unlink(temp_file_path) - async def test_cancel_submission_without_id(self): + def test_cancel_submission_without_id(self): # WHEN I try to cancel a submission without an ID submission = Submission(entity_id="syn123", evaluation_id="456") @@ -478,7 +472,7 @@ def init(self, syn: Synapse, schedule_for_cleanup: Callable[..., None]) -> None: self.syn = syn self.schedule_for_cleanup = schedule_for_cleanup - async def test_get_submission_without_id(self): + def test_get_submission_without_id(self): # WHEN I try to get a submission without an ID submission = Submission(entity_id="syn123", evaluation_id="456") @@ -486,7 +480,7 @@ async def test_get_submission_without_id(self): with pytest.raises(ValueError, match="must have an ID to get"): submission.get(synapse_client=self.syn) - async def test_to_synapse_request_missing_entity_id(self): + def test_to_synapse_request_missing_entity_id(self): # WHEN I try to create a request without entity_id submission = Submission(evaluation_id="456", name="Test") @@ -497,7 +491,7 @@ async def test_to_synapse_request_missing_entity_id(self): ): submission.to_synapse_request() - async def test_to_synapse_request_missing_evaluation_id(self): + def test_to_synapse_request_missing_evaluation_id(self): # WHEN I try to create a request without evaluation_id submission = Submission(entity_id="syn123", name="Test") @@ -505,7 +499,7 @@ async def test_to_synapse_request_missing_evaluation_id(self): with pytest.raises(ValueError, match="missing the 'evaluation_id' attribute"): submission.to_synapse_request() - async def test_to_synapse_request_valid_data(self): + def test_to_synapse_request_valid_data(self): # WHEN I create a request with valid required data submission = Submission( entity_id="syn123456", @@ -528,7 +522,7 @@ async def test_to_synapse_request_valid_data(self): assert request_body["dockerRepositoryName"] == "test/repo" assert request_body["dockerDigest"] == "sha256:abc123" - async def test_to_synapse_request_minimal_data(self): + def test_to_synapse_request_minimal_data(self): # WHEN I create a request with only required data submission = Submission(entity_id="syn123456", evaluation_id="789") @@ -545,7 +539,7 @@ async def test_to_synapse_request_minimal_data(self): class TestSubmissionDataMapping: - async def test_fill_from_dict_complete_data(self): + def test_fill_from_dict_complete_data(self): # GIVEN a complete submission response from the REST API api_response = { "id": "123456", @@ -584,7 +578,7 @@ async def test_fill_from_dict_complete_data(self): assert submission.docker_repository_name == "test/repo" assert submission.docker_digest == "sha256:abc123" - async def test_fill_from_dict_minimal_data(self): + def test_fill_from_dict_minimal_data(self): # GIVEN a minimal submission response from the REST API api_response = { "id": "123456", diff --git a/tests/integration/synapseclient/models/synchronous/test_submission_bundle.py b/tests/integration/synapseclient/models/synchronous/test_submission_bundle.py index 713c89f8b..ef961d4d4 100644 --- a/tests/integration/synapseclient/models/synchronous/test_submission_bundle.py +++ b/tests/integration/synapseclient/models/synchronous/test_submission_bundle.py @@ -26,7 +26,7 @@ def init(self, syn: Synapse, schedule_for_cleanup: Callable[..., None]) -> None: self.schedule_for_cleanup = schedule_for_cleanup @pytest.fixture(scope="function") - async def test_project( + def test_project( self, syn: Synapse, schedule_for_cleanup: Callable[..., None] ) -> Project: project = Project(name=f"test_project_{uuid.uuid4()}").store(synapse_client=syn) @@ -34,7 +34,7 @@ async def test_project( return project @pytest.fixture(scope="function") - async def test_evaluation( + def test_evaluation( self, test_project: Project, syn: Synapse, @@ -51,7 +51,7 @@ async def test_evaluation( return evaluation @pytest.fixture(scope="function") - async def test_file( + def test_file( self, test_project: Project, syn: Synapse, @@ -70,7 +70,7 @@ async def test_file( return file_entity @pytest.fixture(scope="function") - async def test_submission( + def test_submission( self, test_evaluation: Evaluation, test_file: File, @@ -87,7 +87,7 @@ async def test_submission( return submission @pytest.fixture(scope="function") - async def multiple_submissions( + def multiple_submissions( self, test_evaluation: Evaluation, test_file: File, @@ -107,7 +107,7 @@ async def multiple_submissions( submissions.append(submission) return submissions - async def test_get_evaluation_submission_bundles_basic( + def test_get_evaluation_submission_bundles_basic( self, test_evaluation: Evaluation, test_submission: Submission ): """Test getting submission bundles for an evaluation.""" @@ -138,7 +138,7 @@ async def test_get_evaluation_submission_bundles_basic( # AND our test submission should be found assert found_test_bundle, "Test submission should be found in bundles" - async def test_get_evaluation_submission_bundles_generator_behavior( + def test_get_evaluation_submission_bundles_generator_behavior( self, test_evaluation: Evaluation ): """Test that the generator returns SubmissionBundle objects correctly.""" @@ -157,7 +157,7 @@ async def test_get_evaluation_submission_bundles_generator_behavior( # AND all bundles should be valid SubmissionBundle objects assert all(isinstance(bundle, SubmissionBundle) for bundle in bundles) - async def test_get_evaluation_submission_bundles_with_status_filter( + def test_get_evaluation_submission_bundles_with_status_filter( self, test_evaluation: Evaluation, test_submission: Submission ): """Test getting submission bundles filtered by status.""" @@ -192,7 +192,7 @@ async def test_get_evaluation_submission_bundles_with_status_filter( assert "No enum constant" in str(exc_info.value) assert "NONEXISTENT_STATUS" in str(exc_info.value) - async def test_get_evaluation_submission_bundles_generator_behavior_with_multiple( + def test_get_evaluation_submission_bundles_generator_behavior_with_multiple( self, test_evaluation: Evaluation, multiple_submissions: list[Submission] ): """Test generator behavior when getting submission bundles with multiple submissions.""" @@ -228,7 +228,7 @@ async def test_get_evaluation_submission_bundles_generator_behavior_with_multipl bundle_submission_ids ), "All created submissions should be found in bundles" - async def test_get_evaluation_submission_bundles_invalid_evaluation(self): + def test_get_evaluation_submission_bundles_invalid_evaluation(self): """Test getting submission bundles for invalid evaluation ID.""" # WHEN I try to get submission bundles for a non-existent evaluation with pytest.raises(SynapseHTTPError) as exc_info: @@ -242,7 +242,7 @@ async def test_get_evaluation_submission_bundles_invalid_evaluation(self): # THEN it should raise a SynapseHTTPError (likely 403 or 404) assert exc_info.value.response.status_code in [403, 404] - async def test_get_user_submission_bundles_basic( + def test_get_user_submission_bundles_basic( self, test_evaluation: Evaluation, test_submission: Submission ): """Test getting user submission bundles for an evaluation.""" @@ -274,7 +274,7 @@ async def test_get_user_submission_bundles_basic( # AND our test submission should be found assert found_test_bundle, "Test submission should be found in user bundles" - async def test_get_user_submission_bundles_generator_behavior_with_multiple( + def test_get_user_submission_bundles_generator_behavior_with_multiple( self, test_evaluation: Evaluation, multiple_submissions: list[Submission] ): """Test generator behavior when getting user submission bundles with multiple submissions.""" @@ -320,7 +320,7 @@ def init(self, syn: Synapse, schedule_for_cleanup: Callable[..., None]) -> None: self.schedule_for_cleanup = schedule_for_cleanup @pytest.fixture(scope="function") - async def test_project( + def test_project( self, syn: Synapse, schedule_for_cleanup: Callable[..., None] ) -> Project: project = Project(name=f"test_project_{uuid.uuid4()}").store(synapse_client=syn) @@ -328,7 +328,7 @@ async def test_project( return project @pytest.fixture(scope="function") - async def test_evaluation( + def test_evaluation( self, test_project: Project, syn: Synapse, @@ -345,7 +345,7 @@ async def test_evaluation( return evaluation @pytest.fixture(scope="function") - async def test_file( + def test_file( self, test_project: Project, syn: Synapse, @@ -364,7 +364,7 @@ async def test_file( return file_entity @pytest.fixture(scope="function") - async def test_submission( + def test_submission( self, test_evaluation: Evaluation, test_file: File, @@ -380,7 +380,7 @@ async def test_submission( schedule_for_cleanup(submission.id) return submission - async def test_submission_bundle_data_consistency( + def test_submission_bundle_data_consistency( self, test_evaluation: Evaluation, test_submission: Submission, test_file: File ): """Test that submission bundles maintain data consistency between submission and status.""" @@ -412,7 +412,7 @@ async def test_submission_bundle_data_consistency( assert test_bundle.submission_status.id == test_submission.id assert test_bundle.submission_status.entity_id == test_file.id - async def test_submission_bundle_status_updates_reflected( + def test_submission_bundle_status_updates_reflected( self, test_evaluation: Evaluation, test_submission: Submission ): """Test that submission status updates are reflected in bundles.""" @@ -469,7 +469,7 @@ def init(self, syn: Synapse, schedule_for_cleanup: Callable[..., None]) -> None: self.schedule_for_cleanup = schedule_for_cleanup @pytest.fixture(scope="function") - async def test_project( + def test_project( self, syn: Synapse, schedule_for_cleanup: Callable[..., None] ) -> Project: project = Project(name=f"test_project_{uuid.uuid4()}").store(synapse_client=syn) @@ -477,7 +477,7 @@ async def test_project( return project @pytest.fixture(scope="function") - async def test_evaluation( + def test_evaluation( self, test_project: Project, syn: Synapse, @@ -493,7 +493,7 @@ async def test_evaluation( schedule_for_cleanup(evaluation.id) return evaluation - async def test_get_evaluation_submission_bundles_empty_evaluation( + def test_get_evaluation_submission_bundles_empty_evaluation( self, test_evaluation: Evaluation ): """Test getting submission bundles from an evaluation with no submissions.""" @@ -510,7 +510,7 @@ async def test_get_evaluation_submission_bundles_empty_evaluation( assert isinstance(bundles, list) assert len(bundles) == 0 - async def test_get_user_submission_bundles_empty_evaluation( + def test_get_user_submission_bundles_empty_evaluation( self, test_evaluation: Evaluation ): """Test getting user submission bundles from an evaluation with no submissions.""" @@ -527,7 +527,7 @@ async def test_get_user_submission_bundles_empty_evaluation( assert isinstance(bundles, list) assert len(bundles) == 0 - async def test_get_evaluation_submission_bundles_generator_consistency( + def test_get_evaluation_submission_bundles_generator_consistency( self, test_evaluation: Evaluation ): """Test that the generator produces consistent results across multiple iterations.""" @@ -544,7 +544,7 @@ async def test_get_evaluation_submission_bundles_generator_consistency( assert isinstance(bundles, list) # The actual count doesn't matter since the evaluation is empty - async def test_get_user_submission_bundles_generator_empty_results( + def test_get_user_submission_bundles_generator_empty_results( self, test_evaluation: Evaluation ): """Test that user submission bundles generator handles empty results correctly.""" @@ -561,7 +561,7 @@ async def test_get_user_submission_bundles_generator_empty_results( assert isinstance(bundles, list) assert len(bundles) == 0 - async def test_get_submission_bundles_with_default_parameters( + def test_get_submission_bundles_with_default_parameters( self, test_evaluation: Evaluation ): """Test that default parameters work correctly.""" diff --git a/tests/integration/synapseclient/models/synchronous/test_submission_status.py b/tests/integration/synapseclient/models/synchronous/test_submission_status.py index 17087af33..e401b5a96 100644 --- a/tests/integration/synapseclient/models/synchronous/test_submission_status.py +++ b/tests/integration/synapseclient/models/synchronous/test_submission_status.py @@ -35,7 +35,7 @@ def init(self, syn: Synapse, schedule_for_cleanup: Callable[..., None]) -> None: self.schedule_for_cleanup = schedule_for_cleanup @pytest.fixture(scope="function") - async def test_evaluation( + def test_evaluation( self, project_model: Project, syn: Synapse, @@ -54,7 +54,7 @@ async def test_evaluation( return created_evaluation @pytest.fixture(scope="function") - async def test_file( + def test_file( self, project_model: Project, syn: Synapse, @@ -81,7 +81,7 @@ async def test_file( os.unlink(temp_file_path) @pytest.fixture(scope="function") - async def test_submission( + def test_submission( self, test_evaluation: Evaluation, test_file: File, @@ -98,7 +98,7 @@ async def test_submission( schedule_for_cleanup(created_submission.id) return created_submission - async def test_get_submission_status_by_id( + def test_get_submission_status_by_id( self, test_submission: Submission, test_evaluation: Evaluation ): """Test retrieving a submission status by ID.""" @@ -115,7 +115,7 @@ async def test_get_submission_status_by_id( assert submission_status.status_version is not None assert submission_status.modified_on is not None - async def test_get_submission_status_without_id(self): + def test_get_submission_status_without_id(self): """Test that getting a submission status without ID raises ValueError.""" # WHEN I try to get a submission status without an ID submission_status = SubmissionStatus() @@ -126,7 +126,7 @@ async def test_get_submission_status_without_id(self): ): submission_status.get(synapse_client=self.syn) - async def test_get_submission_status_with_invalid_id(self): + def test_get_submission_status_with_invalid_id(self): """Test that getting a submission status with invalid ID raises exception.""" # WHEN I try to get a submission status with an invalid ID submission_status = SubmissionStatus(id="syn999999999999") @@ -145,7 +145,7 @@ def init(self, syn: Synapse, schedule_for_cleanup: Callable[..., None]) -> None: self.schedule_for_cleanup = schedule_for_cleanup @pytest.fixture(scope="function") - async def test_evaluation( + def test_evaluation( self, project_model: Project, syn: Synapse, @@ -164,7 +164,7 @@ async def test_evaluation( return created_evaluation @pytest.fixture(scope="function") - async def test_file( + def test_file( self, project_model: Project, syn: Synapse, @@ -191,7 +191,7 @@ async def test_file( os.unlink(temp_file_path) @pytest.fixture(scope="function") - async def test_submission( + def test_submission( self, test_evaluation: Evaluation, test_file: File, @@ -209,16 +209,14 @@ async def test_submission( return created_submission @pytest.fixture(scope="function") - async def test_submission_status( - self, test_submission: Submission - ) -> SubmissionStatus: + def test_submission_status(self, test_submission: Submission) -> SubmissionStatus: """Create a test submission status by getting the existing one.""" submission_status = SubmissionStatus(id=test_submission.id).get( synapse_client=self.syn ) return submission_status - async def test_store_submission_status_with_status_change( + def test_store_submission_status_with_status_change( self, test_submission_status: SubmissionStatus ): """Test updating a submission status with a status change.""" @@ -238,7 +236,7 @@ async def test_store_submission_status_with_status_change( assert updated_status.etag != original_etag # etag should change assert updated_status.status_version > original_status_version - async def test_store_submission_status_with_submission_annotations( + def test_store_submission_status_with_submission_annotations( self, test_submission_status: SubmissionStatus ): """Test updating a submission status with submission annotations.""" @@ -255,7 +253,7 @@ async def test_store_submission_status_with_submission_annotations( assert updated_status.submission_annotations["score"] == [85.5] assert updated_status.submission_annotations["feedback"] == ["Good work!"] - async def test_store_submission_status_with_legacy_annotations( + def test_store_submission_status_with_legacy_annotations( self, test_submission_status: SubmissionStatus ): """Test updating a submission status with legacy annotations.""" @@ -276,7 +274,7 @@ async def test_store_submission_status_with_legacy_annotations( assert converted_annotations["internal_score"] == 92.3 assert converted_annotations["reviewer_notes"] == "Excellent submission" - async def test_store_submission_status_with_combined_annotations( + def test_store_submission_status_with_combined_annotations( self, test_submission_status: SubmissionStatus ): """Test updating a submission status with both types of annotations.""" @@ -303,7 +301,7 @@ async def test_store_submission_status_with_combined_annotations( assert "internal_review" in converted_annotations assert converted_annotations["internal_review"] == "true" - async def test_store_submission_status_with_private_annotations_false( + def test_store_submission_status_with_private_annotations_false( self, test_submission_status: SubmissionStatus ): """Test updating a submission status with private_status_annotations set to False.""" @@ -331,7 +329,7 @@ async def test_store_submission_status_with_private_annotations_false( annotations = updated_status.annotations[annos_type] assert all(not anno["isPrivate"] for anno in annotations) - async def test_store_submission_status_with_private_annotations_true( + def test_store_submission_status_with_private_annotations_true( self, test_submission_status: SubmissionStatus ): """Test updating a submission status with private_status_annotations set to True (default).""" @@ -365,7 +363,7 @@ async def test_store_submission_status_with_private_annotations_true( print(annotations) assert all(anno["isPrivate"] for anno in annotations) - async def test_store_submission_status_without_id(self): + def test_store_submission_status_without_id(self): """Test that storing a submission status without ID raises ValueError.""" # WHEN I try to store a submission status without an ID submission_status = SubmissionStatus(status="SCORED") @@ -376,7 +374,7 @@ async def test_store_submission_status_without_id(self): ): submission_status.store(synapse_client=self.syn) - async def test_store_submission_status_without_changes( + def test_store_submission_status_without_changes( self, test_submission_status: SubmissionStatus ): """Test that storing a submission status without changes shows warning.""" @@ -389,7 +387,7 @@ async def test_store_submission_status_without_changes( # THEN it should return the same instance (no update sent to Synapse) assert result is test_submission_status - async def test_store_submission_status_change_tracking( + def test_store_submission_status_change_tracking( self, test_submission_status: SubmissionStatus ): """Test that change tracking works correctly.""" @@ -408,7 +406,7 @@ async def test_store_submission_status_change_tracking( # THEN has_changed should be False again assert not updated_status.has_changed - async def test_has_changed_property_edge_cases( + def test_has_changed_property_edge_cases( self, test_submission_status: SubmissionStatus ): """Test the has_changed property with various edge cases and detailed scenarios.""" @@ -469,7 +467,7 @@ def init(self, syn: Synapse, schedule_for_cleanup: Callable[..., None]) -> None: self.schedule_for_cleanup = schedule_for_cleanup @pytest.fixture(scope="function") - async def test_evaluation( + def test_evaluation( self, project_model: Project, syn: Synapse, @@ -488,7 +486,7 @@ async def test_evaluation( return created_evaluation @pytest.fixture(scope="function") - async def test_files( + def test_files( self, project_model: Project, syn: Synapse, @@ -520,7 +518,7 @@ async def test_files( return files @pytest.fixture(scope="function") - async def test_submissions( + def test_submissions( self, test_evaluation: Evaluation, test_files: list[File], @@ -540,7 +538,7 @@ async def test_submissions( submissions.append(created_submission) return submissions - async def test_get_all_submission_statuses( + def test_get_all_submission_statuses( self, test_evaluation: Evaluation, test_submissions: list[Submission] ): """Test getting all submission statuses for an evaluation.""" @@ -564,7 +562,7 @@ async def test_get_all_submission_statuses( assert status.status is not None assert status.etag is not None - async def test_get_all_submission_statuses_with_status_filter( + def test_get_all_submission_statuses_with_status_filter( self, test_evaluation: Evaluation, test_submissions: list[Submission] ): """Test getting submission statuses with status filter.""" @@ -579,7 +577,7 @@ async def test_get_all_submission_statuses_with_status_filter( for status in statuses: assert status.status == "RECEIVED" - async def test_get_all_submission_statuses_with_pagination( + def test_get_all_submission_statuses_with_pagination( self, test_evaluation: Evaluation, test_submissions: list[Submission] ): """Test getting submission statuses with pagination.""" @@ -608,7 +606,7 @@ async def test_get_all_submission_statuses_with_pagination( page2_ids = {status.id for status in statuses_page2} assert page1_ids != page2_ids # Should be different sets - async def test_batch_update_submission_statuses( + def test_batch_update_submission_statuses( self, test_evaluation: Evaluation, test_submissions: list[Submission] ): """Test batch updating multiple submission statuses.""" @@ -657,7 +655,7 @@ def init(self, syn: Synapse, schedule_for_cleanup: Callable[..., None]) -> None: self.schedule_for_cleanup = schedule_for_cleanup @pytest.fixture(scope="function") - async def test_evaluation( + def test_evaluation( self, project_model: Project, syn: Synapse, @@ -676,7 +674,7 @@ async def test_evaluation( return created_evaluation @pytest.fixture(scope="function") - async def test_file( + def test_file( self, project_model: Project, syn: Synapse, @@ -703,7 +701,7 @@ async def test_file( os.unlink(temp_file_path) @pytest.fixture(scope="function") - async def test_submission( + def test_submission( self, test_evaluation: Evaluation, test_file: File, @@ -720,7 +718,7 @@ async def test_submission( schedule_for_cleanup(created_submission.id) return created_submission - async def test_submission_cancellation_workflow(self, test_submission: Submission): + def test_submission_cancellation_workflow(self, test_submission: Submission): """Test the complete submission cancellation workflow.""" # GIVEN a submission that exists submission_id = test_submission.id @@ -757,7 +755,7 @@ def init(self, syn: Synapse, schedule_for_cleanup: Callable[..., None]) -> None: self.syn = syn self.schedule_for_cleanup = schedule_for_cleanup - async def test_to_synapse_request_missing_required_attributes(self): + def test_to_synapse_request_missing_required_attributes(self): """Test that to_synapse_request validates required attributes.""" # WHEN I try to create a request with missing required attributes submission_status = SubmissionStatus(id="123") # Missing etag, status_version @@ -773,7 +771,7 @@ async def test_to_synapse_request_missing_required_attributes(self): with pytest.raises(ValueError, match="missing the 'status_version' attribute"): submission_status.to_synapse_request(synapse_client=self.syn) - async def test_to_synapse_request_valid_attributes(self): + def test_to_synapse_request_valid_attributes(self): """Test that to_synapse_request works with valid attributes.""" # WHEN I create a request with all required attributes submission_status = SubmissionStatus( @@ -794,7 +792,7 @@ async def test_to_synapse_request_valid_attributes(self): assert request_body["status"] == "SCORED" assert "submissionAnnotations" in request_body - async def test_fill_from_dict_with_complete_response(self): + def test_fill_from_dict_with_complete_response(self): """Test filling a SubmissionStatus from a complete API response.""" # GIVEN a complete API response api_response = { @@ -860,7 +858,7 @@ async def test_fill_from_dict_with_complete_response(self): assert result.submission_annotations["feedback"] == ["Great work!"] assert result.submission_annotations["score"] == [92.5] - async def test_fill_from_dict_with_minimal_response(self): + def test_fill_from_dict_with_minimal_response(self): """Test filling a SubmissionStatus from a minimal API response.""" # GIVEN a minimal API response api_response = {"id": "123456", "status": "RECEIVED"} From c1323fb649c0b5953382643e38f4f3ec95e927d7 Mon Sep 17 00:00:00 2001 From: BryanFauble <17128019+BryanFauble@users.noreply.github.com> Date: Tue, 17 Feb 2026 20:26:46 +0000 Subject: [PATCH 4/6] Remove test --- .../integration/synapseclient/test_tables.py | 20 ------------------- 1 file changed, 20 deletions(-) diff --git a/tests/integration/synapseclient/test_tables.py b/tests/integration/synapseclient/test_tables.py index e50021afa..b50a718bd 100644 --- a/tests/integration/synapseclient/test_tables.py +++ b/tests/integration/synapseclient/test_tables.py @@ -313,26 +313,6 @@ def test_materialized_view(syn, project): ) -# @skip("Skip integration tests for soon to be removed code") -def test_dataset(syn, project): - cols = [ - Column(name="id", columnType="ENTITYID"), - Column(name="name", columnType="STRING"), - ] - - dataset = Dataset( - name="Test Pokedex", - parent=project, - dataset_items=[{"entityId": "syn20685093", "versionNumber": 1}], - columns=cols, - addAnnotationColumns=False, - addDefaultViewColumns=False, - ) - dataset = syn.store(dataset) - dataset_df = syn.tableQuery(f"SELECT * FROM {dataset.id}").asDataFrame() - assert all(dataset_df.columns == ["id", "name"]) - - # @skip("Skip integration tests for soon to be removed code") def test_tables_csv(syn, project): # Define schema From bc04a10de6bc477be17218d6c7d8f97849a335f0 Mon Sep 17 00:00:00 2001 From: BryanFauble <17128019+BryanFauble@users.noreply.github.com> Date: Wed, 18 Feb 2026 19:04:30 +0000 Subject: [PATCH 5/6] Updating test structure to more effectively test async/sync tests and combine logic to reduce overall integration tests --- .github/workflows/build.yml | 1 + CONTRIBUTING.md | 55 +- tests/integration/helpers.py | 63 + .../synapseclient/core/test_caching.py | 2 +- .../models/async/test_activity_async.py | 6 +- .../models/async/test_curation_async.py | 79 +- .../models/async/test_evaluation_async.py | 16 +- .../models/async/test_form_async.py | 4 +- .../models/async/test_json_schema_async.py | 134 +- .../async/test_materializedview_async.py | 57 +- .../models/async/test_permissions_async.py | 271 +- .../models/async/test_recordset_async.py | 6 +- .../async/test_schema_organization_async.py | 6 +- .../models/async/test_submission_async.py | 16 +- .../async/test_submission_bundle_async.py | 12 +- .../async/test_submission_status_async.py | 16 +- .../models/async/test_table_async.py | 118 +- .../models/async/test_team_async.py | 2 +- .../models/async/test_virtualtable_async.py | 17 +- .../models/async/test_wiki_async.py | 198 +- .../models/synchronous/test_activity.py | 524 --- .../models/synchronous/test_agent.py | 206 -- .../models/synchronous/test_column.py | 194 -- .../models/synchronous/test_curation.py | 665 ---- .../models/synchronous/test_dataset.py | 656 ---- .../models/synchronous/test_entityview.py | 655 ---- .../models/synchronous/test_evaluation.py | 642 ---- .../models/synchronous/test_file.py | 1783 ----------- .../models/synchronous/test_folder.py | 791 ----- .../models/synchronous/test_form.py | 233 -- .../models/synchronous/test_grid.py | 166 - .../models/synchronous/test_json_schema.py | 576 ---- .../synchronous/test_materializedview.py | 620 ---- .../models/synchronous/test_permissions.py | 2815 ----------------- .../models/synchronous/test_project.py | 730 ----- .../models/synchronous/test_recordset.py | 737 ----- .../synchronous/test_schema_organization.py | 309 -- .../models/synchronous/test_submission.py | 607 ---- .../synchronous/test_submission_bundle.py | 586 ---- .../synchronous/test_submission_status.py | 876 ----- .../models/synchronous/test_submissionview.py | 689 ---- .../synchronous/test_sync_wrapper_smoke.py | 199 ++ .../models/synchronous/test_table.py | 2416 -------------- .../models/synchronous/test_team.py | 189 -- .../models/synchronous/test_user.py | 67 - .../models/synchronous/test_virtualtable.py | 449 --- .../models/synchronous/test_wiki.py | 759 ----- .../operations/synchronous/__init__.py | 0 .../synchronous/test_delete_operations.py | 471 --- .../synchronous/test_factory_operations.py | 795 ----- .../test_factory_operations_store.py | 918 ------ .../synchronous/test_utility_operations.py | 223 -- .../synapseclient/test_evaluations.py | 3 +- .../test_json_schema_services.py | 2 +- .../integration/synapseclient/test_tables.py | 6 +- .../api/unit_test_evaluation_services.py | 664 ++++ .../api/unit_test_team_services.py | 448 +++ .../api/unit_test_user_services.py | 407 +++ .../api/unit_test_wiki_services.py | 537 ++++ .../core/unit_test_async_to_sync.py | 226 ++ .../models/async/unit_test_curation_async.py | 907 ++++++ .../async/unit_test_evaluation_async.py | 1340 ++++++++ .../models/async/unit_test_link_async.py | 998 ++++++ .../models/async/unit_test_recordset_async.py | 840 +++++ .../unit_test_schema_organization_async.py | 979 ++++++ .../models/synchronous/unit_test_activity.py | 344 -- .../models/synchronous/unit_test_agent.py | 595 ---- .../models/synchronous/unit_test_file.py | 1024 ------ .../models/synchronous/unit_test_folder.py | 780 ----- .../models/synchronous/unit_test_form.py | 316 -- .../models/synchronous/unit_test_project.py | 686 ---- .../unit_test_schema_organization.py | 191 -- .../synchronous/unit_test_submission.py | 801 ----- .../unit_test_submission_bundle.py | 459 --- .../unit_test_submission_status.py | 556 ---- .../models/synchronous/unit_test_team.py | 279 -- .../models/synchronous/unit_test_user.py | 356 --- .../models/synchronous/unit_test_wiki.py | 1678 ---------- .../operations/unit_test_delete_operations.py | 484 +++ .../unit_test_factory_operations.py | 759 +++++ .../operations/unit_test_store_operations.py | 649 ++++ 81 files changed, 10100 insertions(+), 29839 deletions(-) create mode 100644 tests/integration/helpers.py delete mode 100644 tests/integration/synapseclient/models/synchronous/test_activity.py delete mode 100644 tests/integration/synapseclient/models/synchronous/test_agent.py delete mode 100644 tests/integration/synapseclient/models/synchronous/test_column.py delete mode 100644 tests/integration/synapseclient/models/synchronous/test_curation.py delete mode 100644 tests/integration/synapseclient/models/synchronous/test_dataset.py delete mode 100644 tests/integration/synapseclient/models/synchronous/test_entityview.py delete mode 100644 tests/integration/synapseclient/models/synchronous/test_evaluation.py delete mode 100644 tests/integration/synapseclient/models/synchronous/test_file.py delete mode 100644 tests/integration/synapseclient/models/synchronous/test_folder.py delete mode 100644 tests/integration/synapseclient/models/synchronous/test_form.py delete mode 100644 tests/integration/synapseclient/models/synchronous/test_grid.py delete mode 100644 tests/integration/synapseclient/models/synchronous/test_json_schema.py delete mode 100644 tests/integration/synapseclient/models/synchronous/test_materializedview.py delete mode 100644 tests/integration/synapseclient/models/synchronous/test_permissions.py delete mode 100644 tests/integration/synapseclient/models/synchronous/test_project.py delete mode 100644 tests/integration/synapseclient/models/synchronous/test_recordset.py delete mode 100644 tests/integration/synapseclient/models/synchronous/test_schema_organization.py delete mode 100644 tests/integration/synapseclient/models/synchronous/test_submission.py delete mode 100644 tests/integration/synapseclient/models/synchronous/test_submission_bundle.py delete mode 100644 tests/integration/synapseclient/models/synchronous/test_submission_status.py delete mode 100644 tests/integration/synapseclient/models/synchronous/test_submissionview.py create mode 100644 tests/integration/synapseclient/models/synchronous/test_sync_wrapper_smoke.py delete mode 100644 tests/integration/synapseclient/models/synchronous/test_table.py delete mode 100644 tests/integration/synapseclient/models/synchronous/test_team.py delete mode 100644 tests/integration/synapseclient/models/synchronous/test_user.py delete mode 100644 tests/integration/synapseclient/models/synchronous/test_virtualtable.py delete mode 100644 tests/integration/synapseclient/models/synchronous/test_wiki.py delete mode 100644 tests/integration/synapseclient/operations/synchronous/__init__.py delete mode 100644 tests/integration/synapseclient/operations/synchronous/test_delete_operations.py delete mode 100644 tests/integration/synapseclient/operations/synchronous/test_factory_operations.py delete mode 100644 tests/integration/synapseclient/operations/synchronous/test_factory_operations_store.py delete mode 100644 tests/integration/synapseclient/operations/synchronous/test_utility_operations.py create mode 100644 tests/unit/synapseclient/api/unit_test_evaluation_services.py create mode 100644 tests/unit/synapseclient/api/unit_test_team_services.py create mode 100644 tests/unit/synapseclient/api/unit_test_user_services.py create mode 100644 tests/unit/synapseclient/api/unit_test_wiki_services.py create mode 100644 tests/unit/synapseclient/core/unit_test_async_to_sync.py create mode 100644 tests/unit/synapseclient/models/async/unit_test_curation_async.py create mode 100644 tests/unit/synapseclient/models/async/unit_test_evaluation_async.py create mode 100644 tests/unit/synapseclient/models/async/unit_test_link_async.py create mode 100644 tests/unit/synapseclient/models/async/unit_test_recordset_async.py create mode 100644 tests/unit/synapseclient/models/async/unit_test_schema_organization_async.py delete mode 100644 tests/unit/synapseclient/models/synchronous/unit_test_activity.py delete mode 100644 tests/unit/synapseclient/models/synchronous/unit_test_agent.py delete mode 100644 tests/unit/synapseclient/models/synchronous/unit_test_file.py delete mode 100644 tests/unit/synapseclient/models/synchronous/unit_test_folder.py delete mode 100644 tests/unit/synapseclient/models/synchronous/unit_test_form.py delete mode 100644 tests/unit/synapseclient/models/synchronous/unit_test_project.py delete mode 100644 tests/unit/synapseclient/models/synchronous/unit_test_schema_organization.py delete mode 100644 tests/unit/synapseclient/models/synchronous/unit_test_submission.py delete mode 100644 tests/unit/synapseclient/models/synchronous/unit_test_submission_bundle.py delete mode 100644 tests/unit/synapseclient/models/synchronous/unit_test_submission_status.py delete mode 100644 tests/unit/synapseclient/models/synchronous/unit_test_team.py delete mode 100644 tests/unit/synapseclient/models/synchronous/unit_test_user.py delete mode 100644 tests/unit/synapseclient/models/synchronous/unit_test_wiki.py create mode 100644 tests/unit/synapseclient/operations/unit_test_delete_operations.py create mode 100644 tests/unit/synapseclient/operations/unit_test_factory_operations.py create mode 100644 tests/unit/synapseclient/operations/unit_test_store_operations.py diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index d7490657c..a3d6b8cc9 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -46,6 +46,7 @@ jobs: # run unit (and integration tests if account secrets available) on our build matrix test: needs: [pre-commit] + timeout-minutes: 180 strategy: fail-fast: false diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 7b77e0586..461b54122 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -180,25 +180,28 @@ Now that you have chosen a JIRA ticket and have your own fork of this repository All code added to the client must have tests. These might include unit tests (to test specific functionality of code that was added to support fixing the bug or feature), integration tests (to test that the feature is usable - e.g., it should have complete the expected behavior as reported in the feature request or bug report), or both. -The Python client uses [`pytest`](https://docs.pytest.org/en/latest/) to run tests. The test code is located in the [test](./test) subdirectory. +The Python client uses [`pytest`](https://docs.pytest.org/en/latest/) to run tests. The test code is located in the [tests](./tests) subdirectory. Here's how to run the test suite: -> *Note:* The entire set of tests takes approximately 20 minutes to run. - ``` -# Unit tests -pytest -vs tests/unit +# Unit tests (~1-2 minutes) +pytest -sv tests/unit + +# Integration tests (requires Synapse credentials, ~30-60 minutes) +# Uses pytest-xdist for parallel execution with fixture-aware distribution +pytest -sv tests/integration -n 8 --dist loadscope -# Integration tests - The integration tests should be run against the `dev` synapse server -pytest -vs tests/integration +# Integration tests excluding CLI tests (which must run serially) +pytest -sv tests/integration -n 8 --dist loadscope \ + --ignore=tests/integration/synapseclient/test_command_line_client.py ``` To test a specific feature, specify the full path to the function to run: ``` # Test table query functionality from the command line client -pytest -vs tests/integration/synapseclient/test_command_line_client.py::test_table_query +pytest -sv tests/integration/synapseclient/test_command_line_client.py::test_table_query ```` #### Integration testing against the `dev` synapse server @@ -244,8 +247,8 @@ When adding support for a new Python version (e.g., adding Python 3.15), update **CI/CD configuration files:** 1. **`.github/workflows/build.yml`**: - - Add the new version to the `python` matrix under the `test` job strategy - - Ensure the new version is included in integration test runs (typically the latest version should be tested) + - Add the new version to the `unit-tests` job matrix (all Python versions run on Ubuntu; only one version per macOS/Windows) + - Add the new version to the `integration-tests` job matrix if it should be integration-tested (typically the oldest and newest supported versions) - Update any Python version comments or documentation within the workflow **Testing:** @@ -269,7 +272,7 @@ When dropping support for an old Python version (e.g., removing Python 3.10), up **CI/CD configuration files:** 1. **`.github/workflows/build.yml`**: - - Remove the old version from the `python` matrix under the `test` job strategy + - Remove the old version from the `unit-tests` and `integration-tests` job matrices - Update the cache key version (e.g., increment `v28` to `v29`) to invalidate old caches **Documentation:** @@ -315,10 +318,10 @@ available at runtime: 1. Update the examples in the docstring to remove the await or async function calls. 1. Import the protocol class you created and add it to the class constructor to inherit the protocol class. -1. Write unit and integration tests for BOTH the async and non-async versions. - 1. Write your tests once with async in mind. - 1. Copy them to a non-async testing directory. - 1. Remove the async-related keywords and imports. +1. Write unit and integration tests for the **async** version only. + 1. The `@async_to_sync` decorator automatically generates the sync wrapper at runtime. + 1. The sync wrapper is covered by dedicated unit tests in `tests/unit/synapseclient/core/unit_test_async_to_sync.py` and a smoke integration test in `tests/integration/synapseclient/models/synchronous/test_sync_wrapper_smoke.py`. + 1. **Do not** create duplicate synchronous test files. 1. Add the method definitions to the appropriate markdown file for generated doc pages. ##### Creating a new async method to be called internally by the client @@ -331,8 +334,9 @@ generation of non-async code. ##### Modifying an existing async method When you make a modification to an async method please also copy any changes to the -definition of the method OR docstring into the non-async method defintion. It is -expected that you manually keep them in-sync. +definition of the method OR docstring into the non-async Protocol class definition. The +sync wrapper is generated automatically by the `@async_to_sync` decorator, so only the +Protocol class (used for static type checking) needs to be updated manually. ### Code style @@ -430,13 +434,16 @@ following set of guidelines should be followed: - **Test function signatures:** - Tests in `tests/integration/synapseclient/models/async/` should use `async def` to test async methods - - Tests in `tests/integration/synapseclient/models/synchronous/` should use regular `def` to test synchronous methods - - **Important (Python 3.14+):** Synchronous tests must NOT use `async def`, as this creates an active event loop that prevents synchronous methods from working correctly -- **Fixtures:** Can be `async def` if they need to call async methods, but should use regular `def` when calling synchronous methods -- Any non `session` scoped fixtures should not execute an HTTP request. If the fixture -does need to execute a request it should not be scoped to `function`. This is because -each scope level runs it's own event loop; Connection pools cannot be shared between -each of the event loops. + - A single sync wrapper smoke test in `tests/integration/synapseclient/models/synchronous/test_sync_wrapper_smoke.py` covers the `@async_to_sync` decorator against the real API. **Do not** create additional per-model synchronous integration test files. + - **Important (Python 3.14+):** Synchronous tests must NOT use `async def`, as this creates an active event loop that prevents synchronous methods from working correctly. The sync smoke test is skipped on Python 3.14+. +- **Fixtures:** Can be `async def` if they need to call async methods, but should use regular `def` when calling synchronous methods. +- **Fixture scoping guidelines:** + - `session` scope: Use for the Synapse client (`syn`), shared project (`project_model`), and `schedule_for_cleanup`. These are created once per test session/worker. + - `class` scope: Use for expensive container entities (Projects, Evaluations, Tables, Folders with files) that are **not mutated** by tests — only used as parents or read-only containers. This avoids redundant entity creation across tests within a class. + - `function` scope: Use for entities that tests **mutate** (e.g., files with changed names, datasets with added/removed items, submission statuses being updated). Each test gets a fresh entity. + - All fixtures that create Synapse entities **must** call `schedule_for_cleanup()` to register them for cleanup at session end. +- **Polling and retries:** For eventual-consistency scenarios (e.g., waiting for permission propagation, schema binding, attachment preview generation), use `wait_for_condition()` from `tests/integration/helpers.py` instead of hardcoded `asyncio.sleep()` calls. This uses exponential backoff and returns as soon as the condition is met. +- **Parallel execution:** Tests run with `pytest -n 8 --dist loadscope`, which ensures all tests in a class execute on the same worker sequentially. Session-scoped fixtures are shared within each worker. ### Repository Admins diff --git a/tests/integration/helpers.py b/tests/integration/helpers.py new file mode 100644 index 000000000..53cab0b31 --- /dev/null +++ b/tests/integration/helpers.py @@ -0,0 +1,63 @@ +"""Shared test helpers for integration tests.""" + +import asyncio +import logging +from typing import Any, Awaitable, Callable, Optional, TypeVar, Union + +logger = logging.getLogger(__name__) + +T = TypeVar("T") + + +async def wait_for_condition( + condition_fn: Callable[[], Union[Awaitable[T], T]], + timeout_seconds: float = 60, + poll_interval_seconds: float = 2, + backoff_factor: float = 1.5, + max_interval_seconds: float = 15, + description: str = "condition", +) -> T: + """Poll until condition_fn returns a truthy value, with exponential backoff. + + Args: + condition_fn: A callable (sync or async) that returns a truthy value when + the condition is met, or a falsy value to keep polling. If it raises + an exception, polling continues until timeout. + timeout_seconds: Maximum time to wait before raising TimeoutError. + poll_interval_seconds: Initial interval between polls. + backoff_factor: Multiplier applied to the interval after each poll. + max_interval_seconds: Cap on the poll interval. + description: Human-readable description for error messages. + + Returns: + The truthy value returned by condition_fn. + + Raises: + TimeoutError: If the condition is not met within timeout_seconds. + """ + elapsed = 0.0 + interval = poll_interval_seconds + last_exception: Optional[Exception] = None + + while elapsed < timeout_seconds: + try: + result = condition_fn() + if asyncio.iscoroutine(result) or asyncio.isfuture(result): + result = await result + if result: + return result + except Exception as ex: + last_exception = ex + logger.debug(f"Polling for {description}: caught {type(ex).__name__}: {ex}") + + wait_time = min(interval, timeout_seconds - elapsed) + if wait_time <= 0: + break + await asyncio.sleep(wait_time) + elapsed += wait_time + interval = min(interval * backoff_factor, max_interval_seconds) + + msg = f"Timed out waiting for {description} after {timeout_seconds}s" + if last_exception: + msg += f" (last error: {type(last_exception).__name__}: {last_exception})" + raise TimeoutError(msg) diff --git a/tests/integration/synapseclient/core/test_caching.py b/tests/integration/synapseclient/core/test_caching.py index 82d29b03c..08392d6e4 100644 --- a/tests/integration/synapseclient/core/test_caching.py +++ b/tests/integration/synapseclient/core/test_caching.py @@ -37,7 +37,7 @@ def syn_state(syn): async def sleep_and_end_test(syn: Synapse) -> None: """Exit the test after sleeping""" - await asyncio.sleep(20) + await asyncio.sleep(10) syn.test_keepRunning = False diff --git a/tests/integration/synapseclient/models/async/test_activity_async.py b/tests/integration/synapseclient/models/async/test_activity_async.py index 2ca65b38d..d55f06927 100644 --- a/tests/integration/synapseclient/models/async/test_activity_async.py +++ b/tests/integration/synapseclient/models/async/test_activity_async.py @@ -244,7 +244,7 @@ async def test_get_by_parent_id(self, project: Synapse_Project) -> None: ) file = await self.create_file_with_activity(project, activity=activity) stored_activity = file.activity - await asyncio.sleep(2) + await asyncio.sleep(1) # WHEN I retrieve the activity by parent ID retrieved_activity = await Activity.get_async( @@ -277,7 +277,7 @@ async def test_get_by_parent_id_with_version( ) file = await self.create_file_with_activity(project, activity=activity) stored_activity = file.activity - await asyncio.sleep(2) + await asyncio.sleep(1) # WHEN I retrieve the activity by parent ID with version retrieved_activity = await Activity.get_async( @@ -335,7 +335,7 @@ async def test_get_activity_id_takes_precedence( stored_activity1 = file1.activity stored_activity2 = file2.activity - await asyncio.sleep(2) + await asyncio.sleep(1) # WHEN I retrieve using activity_id from first activity and parent_id from second retrieved_activity = await Activity.get_async( diff --git a/tests/integration/synapseclient/models/async/test_curation_async.py b/tests/integration/synapseclient/models/async/test_curation_async.py index 4422524a9..a07183e67 100644 --- a/tests/integration/synapseclient/models/async/test_curation_async.py +++ b/tests/integration/synapseclient/models/async/test_curation_async.py @@ -32,17 +32,20 @@ def init(self, syn: Synapse, schedule_for_cleanup: Callable[..., None]) -> None: self.syn = syn self.schedule_for_cleanup = schedule_for_cleanup - @pytest.fixture(scope="function") + @pytest.fixture(scope="class") async def folder_with_view( - self, project_model: Project + self, + project_model: Project, + syn: Synapse, + schedule_for_cleanup: Callable[..., None], ) -> tuple[Folder, EntityView]: """Create a folder with an associated EntityView for file-based testing.""" # Create a folder folder = await Folder( name=str(uuid.uuid4()), parent_id=project_model.id, - ).store_async(synapse_client=self.syn) - self.schedule_for_cleanup(folder.id) + ).store_async(synapse_client=syn) + schedule_for_cleanup(folder.id) # Create an EntityView for the folder columns = [ @@ -66,19 +69,24 @@ async def folder_with_view( scope_ids=[folder.id], view_type_mask=ViewTypeMask.FILE.value, columns=columns, - ).store_async(synapse_client=self.syn) - self.schedule_for_cleanup(entity_view.id) + ).store_async(synapse_client=syn) + schedule_for_cleanup(entity_view.id) return folder, entity_view - @pytest.fixture(scope="function") - async def record_set(self, project_model: Project) -> RecordSet: + @pytest.fixture(scope="class") + async def record_set( + self, + project_model: Project, + syn: Synapse, + schedule_for_cleanup: Callable[..., None], + ) -> RecordSet: """Create a RecordSet for record-based testing.""" folder = await Folder( name=str(uuid.uuid4()), parent_id=project_model.id, - ).store_async(synapse_client=self.syn) - self.schedule_for_cleanup(folder.id) + ).store_async(synapse_client=syn) + schedule_for_cleanup(folder.id) # Create test data as a pandas DataFrame test_data = pd.DataFrame( @@ -108,15 +116,15 @@ async def record_set(self, project_model: Project) -> RecordSet: try: os.close(temp_fd) # Close the file descriptor test_data.to_csv(filename, index=False) - self.schedule_for_cleanup(filename) + schedule_for_cleanup(filename) record_set = await RecordSet( name=str(uuid.uuid4()), parent_id=folder.id, path=filename, upsert_keys=["title", "regional_cuisine"], - ).store_async(synapse_client=self.syn) - self.schedule_for_cleanup(record_set.id) + ).store_async(synapse_client=syn) + schedule_for_cleanup(record_set.id) return record_set except Exception: @@ -253,17 +261,20 @@ def init(self, syn: Synapse, schedule_for_cleanup: Callable[..., None]) -> None: self.syn = syn self.schedule_for_cleanup = schedule_for_cleanup - @pytest.fixture(scope="function") + @pytest.fixture(scope="class") async def folder_with_view( - self, project_model: Project + self, + project_model: Project, + syn: Synapse, + schedule_for_cleanup: Callable[..., None], ) -> tuple[Folder, EntityView]: """Create a folder with an associated EntityView for file-based testing.""" # Create a folder folder = await Folder( name=str(uuid.uuid4()), parent_id=project_model.id, - ).store_async(synapse_client=self.syn) - self.schedule_for_cleanup(folder.id) + ).store_async(synapse_client=syn) + schedule_for_cleanup(folder.id) # Create required columns for the EntityView columns = [ @@ -287,8 +298,8 @@ async def folder_with_view( scope_ids=[folder.id], view_type_mask=ViewTypeMask.FILE.value, columns=columns, - ).store_async(synapse_client=self.syn) - self.schedule_for_cleanup(entity_view.id) + ).store_async(synapse_client=syn) + schedule_for_cleanup(entity_view.id) return folder, entity_view @@ -359,17 +370,20 @@ def init(self, syn: Synapse, schedule_for_cleanup: Callable[..., None]) -> None: self.syn = syn self.schedule_for_cleanup = schedule_for_cleanup - @pytest.fixture(scope="function") + @pytest.fixture(scope="class") async def folder_with_view( - self, project_model: Project + self, + project_model: Project, + syn: Synapse, + schedule_for_cleanup: Callable[..., None], ) -> tuple[Folder, EntityView]: """Create a folder with an associated EntityView for file-based testing.""" # Create a folder folder = await Folder( name=str(uuid.uuid4()), parent_id=project_model.id, - ).store_async(synapse_client=self.syn) - self.schedule_for_cleanup(folder.id) + ).store_async(synapse_client=syn) + schedule_for_cleanup(folder.id) # Create required columns for the EntityView columns = [ @@ -393,8 +407,8 @@ async def folder_with_view( scope_ids=[folder.id], view_type_mask=ViewTypeMask.FILE.value, columns=columns, - ).store_async(synapse_client=self.syn) - self.schedule_for_cleanup(entity_view.id) + ).store_async(synapse_client=syn) + schedule_for_cleanup(entity_view.id) return folder, entity_view @@ -447,17 +461,20 @@ def init(self, syn: Synapse, schedule_for_cleanup: Callable[..., None]) -> None: self.syn = syn self.schedule_for_cleanup = schedule_for_cleanup - @pytest.fixture(scope="function") + @pytest.fixture(scope="class") async def folder_with_view( - self, project_model: Project + self, + project_model: Project, + syn: Synapse, + schedule_for_cleanup: Callable[..., None], ) -> tuple[Folder, EntityView]: """Create a folder with an associated EntityView for file-based testing.""" # Create a folder folder = await Folder( name=str(uuid.uuid4()), parent_id=project_model.id, - ).store_async(synapse_client=self.syn) - self.schedule_for_cleanup(folder.id) + ).store_async(synapse_client=syn) + schedule_for_cleanup(folder.id) # Create required columns for the EntityView columns = [ @@ -481,8 +498,8 @@ async def folder_with_view( scope_ids=[folder.id], view_type_mask=ViewTypeMask.FILE.value, columns=columns, - ).store_async(synapse_client=self.syn) - self.schedule_for_cleanup(entity_view.id) + ).store_async(synapse_client=syn) + schedule_for_cleanup(entity_view.id) return folder, entity_view diff --git a/tests/integration/synapseclient/models/async/test_evaluation_async.py b/tests/integration/synapseclient/models/async/test_evaluation_async.py index 20448d05a..d2a26e078 100644 --- a/tests/integration/synapseclient/models/async/test_evaluation_async.py +++ b/tests/integration/synapseclient/models/async/test_evaluation_async.py @@ -53,13 +53,13 @@ def init(self, syn: Synapse, schedule_for_cleanup: Callable[..., None]) -> None: self.syn = syn self.schedule_for_cleanup = schedule_for_cleanup - @pytest.fixture(scope="function") + @pytest.fixture(scope="class") async def test_project( self, syn: Synapse, schedule_for_cleanup: Callable[..., None] ) -> Project: """Create a test project for evaluation tests.""" project = await Project(name=f"test_project_{uuid.uuid4()}").store_async( - synapse_client=self.syn + synapse_client=syn ) schedule_for_cleanup(project.id) return project @@ -209,13 +209,13 @@ def init(self, syn: Synapse, schedule_for_cleanup: Callable[..., None]) -> None: self.syn = syn self.schedule_for_cleanup = schedule_for_cleanup - @pytest.fixture(scope="function") + @pytest.fixture(scope="class") async def test_project( self, syn: Synapse, schedule_for_cleanup: Callable[..., None] ) -> Project: """Create a test project for evaluation tests.""" project = await Project(name=f"test_project_{uuid.uuid4()}").store_async( - synapse_client=self.syn + synapse_client=syn ) schedule_for_cleanup(project.id) return project @@ -340,13 +340,13 @@ def init(self, syn: Synapse, schedule_for_cleanup: Callable[..., None]) -> None: self.syn = syn self.schedule_for_cleanup = schedule_for_cleanup - @pytest.fixture(scope="function") + @pytest.fixture(scope="class") async def test_project( self, syn: Synapse, schedule_for_cleanup: Callable[..., None] ) -> Project: """Create a test project for evaluation tests.""" project = await Project(name=f"test_project_{uuid.uuid4()}").store_async( - synapse_client=self.syn + synapse_client=syn ) schedule_for_cleanup(project.id) return project @@ -385,13 +385,13 @@ def init(self, syn: Synapse, schedule_for_cleanup: Callable[..., None]) -> None: self.syn = syn self.schedule_for_cleanup = schedule_for_cleanup - @pytest.fixture(scope="function") + @pytest.fixture(scope="class") async def test_project( self, syn: Synapse, schedule_for_cleanup: Callable[..., None] ) -> Project: """Create a test project for evaluation tests.""" project = await Project(name=f"test_project_{uuid.uuid4()}").store_async( - synapse_client=self.syn + synapse_client=syn ) schedule_for_cleanup(project.id) return project diff --git a/tests/integration/synapseclient/models/async/test_form_async.py b/tests/integration/synapseclient/models/async/test_form_async.py index 974e7a5b7..00507eea4 100644 --- a/tests/integration/synapseclient/models/async/test_form_async.py +++ b/tests/integration/synapseclient/models/async/test_form_async.py @@ -41,7 +41,7 @@ async def test_raise_error_on_missing_name(self, syn) -> None: class TestFormData: - @pytest.fixture(autouse=True, scope="session") + @pytest.fixture(autouse=True, scope="class") async def test_form_group( self, syn: Synapse, schedule_for_cleanup: Callable[..., None] ) -> FormGroup: @@ -54,7 +54,7 @@ async def test_form_group( return form_group - @pytest.fixture(autouse=True, scope="session") + @pytest.fixture(autouse=True, scope="class") async def test_file( self, syn: Synapse, schedule_for_cleanup: Callable[..., None] ) -> File: diff --git a/tests/integration/synapseclient/models/async/test_json_schema_async.py b/tests/integration/synapseclient/models/async/test_json_schema_async.py index dfbf8e186..d723b009b 100644 --- a/tests/integration/synapseclient/models/async/test_json_schema_async.py +++ b/tests/integration/synapseclient/models/async/test_json_schema_async.py @@ -19,6 +19,7 @@ ViewTypeMask, ) from synapseclient.services.json_schema import JsonSchemaOrganization +from tests.integration.helpers import wait_for_condition DESCRIPTION_FOLDER = "This is a folder for testing JSON schema functionality." DESCRIPTION_FILE = "This is an example file." @@ -293,11 +294,24 @@ async def test_get_schema_derived_keys_async( response = await created_entity.get_schema_async(synapse_client=self.syn) assert response.enable_derived_annotations == True - await asyncio.sleep(2) - - # Retrieve the derived keys from the folder - response = await created_entity.get_schema_derived_keys_async( - synapse_client=self.syn + # Poll until derived keys are populated (backend needs time to process) + async def _get_derived_keys(): + try: + result = await created_entity.get_schema_derived_keys_async( + synapse_client=self.syn + ) + # Keys are empty until backend finishes deriving them + if result and result.keys: + return result + return None + except SynapseHTTPError: + return None + + response = await wait_for_condition( + _get_derived_keys, + timeout_seconds=30, + poll_interval_seconds=2, + description="schema derived keys to be populated", ) assert set(response.keys) == {"productId", "productName"} @@ -340,12 +354,28 @@ async def test_validate_schema_async_invalid_annos( "productQuantity": "invalid string", } await created_entity.store_async(synapse_client=self.syn) - # Ensure annotations are stored - await asyncio.sleep(2) - # Validate the folder against the JSON schema - response = await created_entity.validate_schema_async( - synapse_client=self.syn + # Poll until validation results are available + async def _validate_invalid(): + try: + r = await created_entity.validate_schema_async( + synapse_client=self.syn + ) + if ( + r + and r.validation_response + and r.validation_response.is_valid is False + ): + return r + return None + except SynapseHTTPError: + return None + + response = await wait_for_condition( + _validate_invalid, + timeout_seconds=30, + poll_interval_seconds=2, + description="schema validation results (invalid) to be available", ) assert response.validation_response.is_valid == False assert response.validation_response.id is not None @@ -398,10 +428,24 @@ async def test_validate_schema_async_valid_annos( "productQuantity": 100, } await created_entity.store_async(synapse_client=self.syn) - # Ensure annotations are stored - await asyncio.sleep(2) - response = await created_entity.validate_schema_async( - synapse_client=self.syn + + # Poll until validation results are available + async def _validate_valid(): + try: + r = await created_entity.validate_schema_async( + synapse_client=self.syn + ) + if r and r.is_valid is True: + return r + return None + except SynapseHTTPError: + return None + + response = await wait_for_condition( + _validate_valid, + timeout_seconds=30, + poll_interval_seconds=2, + description="schema validation results (valid) to be available", ) assert response.is_valid == True finally: @@ -471,15 +515,30 @@ async def test_get_validation_statistics_async( await file_1.store_async(parent=created_entity, synapse_client=self.syn) await file_2.store_async(parent=created_entity, synapse_client=self.syn) - # Ensure annotations are stored - await asyncio.sleep(2) - # validate the entity against the JSON SCHEMA - await created_entity.validate_schema_async(synapse_client=self.syn) - - # Get validation statistics of the entity - response = await created_entity.get_schema_validation_statistics_async( - synapse_client=self.syn + # Poll until validation statistics reflect both children + async def _get_stats(): + try: + # Trigger validation first + await created_entity.validate_schema_async(synapse_client=self.syn) + stats = await created_entity.get_schema_validation_statistics_async( + synapse_client=self.syn + ) + if ( + stats + and stats.number_of_valid_children == 1 + and stats.number_of_invalid_children == 1 + ): + return stats + return None + except SynapseHTTPError: + return None + + response = await wait_for_condition( + _get_stats, + timeout_seconds=30, + poll_interval_seconds=2, + description="validation statistics to reflect both children", ) assert response.number_of_valid_children == 1 assert response.number_of_invalid_children == 1 @@ -546,14 +605,29 @@ async def test_get_invalid_validation_async( await file_1.store_async(parent=created_entity, synapse_client=self.syn) await file_2.store_async(parent=created_entity, synapse_client=self.syn) - # Ensure annotations are stored - await asyncio.sleep(2) - - # Get invalid validation results of the folder - # The generator `gen` yields validation results for entities that failed JSON schema validation. - # Each item in `gen` is expected to be a dictionary containing details about the validation failure. - gen = created_entity.get_invalid_validation_async(synapse_client=self.syn) - async for item in gen: + + # Poll until invalid validation results are available + async def _get_invalid_results(): + try: + results = [] + gen = created_entity.get_invalid_validation_async( + synapse_client=self.syn + ) + async for item in gen: + results.append(item) + return results if results else None + except SynapseHTTPError: + return None + + invalid_results = await wait_for_condition( + _get_invalid_results, + timeout_seconds=30, + poll_interval_seconds=2, + description="invalid validation results to be available", + ) + + # Verify the invalid validation results + for item in invalid_results: validation_response = item.validation_response validation_error_message = item.validation_error_message validation_exception = item.validation_exception diff --git a/tests/integration/synapseclient/models/async/test_materializedview_async.py b/tests/integration/synapseclient/models/async/test_materializedview_async.py index f345d1fd8..c9abcb2d1 100644 --- a/tests/integration/synapseclient/models/async/test_materializedview_async.py +++ b/tests/integration/synapseclient/models/async/test_materializedview_async.py @@ -212,8 +212,14 @@ def init(self, syn: Synapse, schedule_for_cleanup: Callable[..., None]) -> None: self.syn = syn self.schedule_for_cleanup = schedule_for_cleanup - async def setup_table_with_data(self, project_model: Project): - """Helper method to create a table with data for testing""" + @pytest.fixture(scope="class") + async def base_table_with_data( + self, + project_model: Project, + syn: Synapse, + schedule_for_cleanup: Callable[..., None], + ) -> Table: + """Create a table with data, shared across all tests in this class.""" table_name = str(uuid.uuid4()) table = Table( name=table_name, @@ -223,18 +229,20 @@ async def setup_table_with_data(self, project_model: Project): Column(name="age", column_type=ColumnType.INTEGER), ], ) - table = await table.store_async(synapse_client=self.syn) - self.schedule_for_cleanup(table.id) + table = await table.store_async(synapse_client=syn) + schedule_for_cleanup(table.id) # Insert data into the table data = pd.DataFrame({"name": ["Alice", "Bob"], "age": [30, 25]}) - await table.store_rows_async(data, synapse_client=self.syn) + await table.store_rows_async(data, synapse_client=syn) return table - async def test_query_materialized_view(self, project_model: Project) -> None: + async def test_query_materialized_view( + self, project_model: Project, base_table_with_data: Table + ) -> None: # GIVEN a table with data - table = await self.setup_table_with_data(project_model) + table = base_table_with_data # AND a materialized view based on the table materialized_view = MaterializedView( @@ -257,9 +265,11 @@ async def test_query_materialized_view(self, project_model: Project) -> None: assert query_result["name"].tolist() == ["Alice", "Bob"] assert query_result["age"].tolist() == [30, 25] - async def test_update_defining_sql(self, project_model: Project) -> None: + async def test_update_defining_sql( + self, project_model: Project, base_table_with_data: Table + ) -> None: # GIVEN a table with data - table = await self.setup_table_with_data(project_model) + table = base_table_with_data # AND a materialized view based on the table materialized_view = MaterializedView( @@ -275,7 +285,7 @@ async def test_update_defining_sql(self, project_model: Project) -> None: materialized_view = await materialized_view.store_async(synapse_client=self.syn) # AND querying the materialized view (with delay for eventual consistency) - await asyncio.sleep(5) + await asyncio.sleep(2) query_result = await materialized_view.query_async( f"SELECT * FROM {materialized_view.id}", synapse_client=self.syn, @@ -334,13 +344,13 @@ async def test_materialized_view_reflects_table_updates( await table.store_rows_async(data, synapse_client=self.syn) # AND querying again (with delay for eventual consistency) - await asyncio.sleep(5) + await asyncio.sleep(2) query_result = await materialized_view.query_async( f"SELECT * FROM {materialized_view.id}", synapse_client=self.syn, timeout=QUERY_TIMEOUT_SEC, ) - await asyncio.sleep(5) + await asyncio.sleep(2) query_result = await materialized_view.query_async( f"SELECT * FROM {materialized_view.id}", synapse_client=self.syn, @@ -356,7 +366,20 @@ async def test_materialized_view_reflects_table_data_removal( self, project_model: Project ) -> None: # GIVEN a table with data - table = await self.setup_table_with_data(project_model) + table_name = str(uuid.uuid4()) + table = Table( + name=table_name, + parent_id=project_model.id, + columns=[ + Column(name="name", column_type=ColumnType.STRING), + Column(name="age", column_type=ColumnType.INTEGER), + ], + ) + table = await table.store_async(synapse_client=self.syn) + self.schedule_for_cleanup(table.id) + + data = pd.DataFrame({"name": ["Alice", "Bob"], "age": [30, 25]}) + await table.store_rows_async(data, synapse_client=self.syn) # AND a materialized view based on the table materialized_view = MaterializedView( @@ -373,7 +396,7 @@ async def test_materialized_view_reflects_table_data_removal( ) # AND querying the materialized view (with delay for eventual consistency) - await asyncio.sleep(5) + await asyncio.sleep(2) query_result = await materialized_view.query_async( f"SELECT * FROM {materialized_view.id}", synapse_client=self.syn, @@ -388,9 +411,11 @@ async def test_materialized_view_reflects_table_data_removal( # THEN the query results should reflect the removed data assert len(query_result) == 0 - async def test_query_part_mask_async(self, project_model: Project) -> None: + async def test_query_part_mask_async( + self, project_model: Project, base_table_with_data: Table + ) -> None: # GIVEN a table with data - table = await self.setup_table_with_data(project_model) + table = base_table_with_data # AND a materialized view based on the table materialized_view = MaterializedView( diff --git a/tests/integration/synapseclient/models/async/test_permissions_async.py b/tests/integration/synapseclient/models/async/test_permissions_async.py index 60346acf8..3e710c031 100644 --- a/tests/integration/synapseclient/models/async/test_permissions_async.py +++ b/tests/integration/synapseclient/models/async/test_permissions_async.py @@ -2,7 +2,6 @@ import asyncio import logging -import random import uuid from typing import Callable, Dict, List, Optional, Type, Union @@ -29,6 +28,7 @@ ViewTypeMask, VirtualTable, ) +from tests.integration.helpers import wait_for_condition PUBLIC = 273949 # PrincipalId of public "user" AUTHENTICATED_USERS = 273948 @@ -502,7 +502,7 @@ async def test_table_permissions(self, project_model: Project) -> None: synapse_client=self.syn, ) - await asyncio.sleep(random.randint(1, 5)) + await asyncio.sleep(1) # THEN listing permissions should show all set permissions # Check team permissions @@ -543,7 +543,7 @@ async def test_table_permissions(self, project_model: Project) -> None: principal_id=team.id, access_type=[], synapse_client=self.syn ) - await asyncio.sleep(random.randint(1, 5)) + await asyncio.sleep(1) # THEN team should no longer have permissions team_acl_after_delete = await table.get_acl_async( @@ -604,7 +604,7 @@ async def test_entity_view_permissions(self, project_model: Project) -> None: synapse_client=self.syn, ) - await asyncio.sleep(random.randint(1, 5)) + await asyncio.sleep(1) # THEN listing permissions should reflect all changes # Verify team permissions @@ -638,7 +638,7 @@ async def test_entity_view_permissions(self, project_model: Project) -> None: principal_id=AUTHENTICATED_USERS, access_type=[], synapse_client=self.syn ) - await asyncio.sleep(random.randint(1, 5)) + await asyncio.sleep(1) # THEN authenticated users should lose permissions auth_acl_after = await entity_view.get_acl_async( @@ -706,7 +706,7 @@ async def test_submission_view_permissions(self, project_model: Project) -> None synapse_client=self.syn, ) - await asyncio.sleep(random.randint(1, 5)) + await asyncio.sleep(1) # THEN listing permissions should show proper aggregation # Check individual team permissions @@ -747,7 +747,7 @@ async def test_submission_view_permissions(self, project_model: Project) -> None principal_id=team1.id, access_type=[], synapse_client=self.syn ) - await asyncio.sleep(random.randint(1, 5)) + await asyncio.sleep(1) # THEN PUBLIC should lose all permissions public_acl_after = await submission_view.get_acl_async( @@ -963,8 +963,14 @@ def init( self.verification_attempts = 10 @pytest.fixture(scope="function") - def project_object(self) -> Project: - return Project(name="integration_test_project" + str(uuid.uuid4())) + async def stored_project( + self, syn: Synapse, schedule_for_cleanup: Callable[..., None] + ) -> Project: + project = await Project( + name="integration_test_project" + str(uuid.uuid4()) + ).store_async(synapse_client=syn) + schedule_for_cleanup(project.id) + return project @pytest.fixture(scope="function") def file(self, schedule_for_cleanup: Callable[..., None]) -> File: @@ -994,43 +1000,45 @@ async def _set_custom_permissions( async def _verify_permissions_deleted( self, entity: Union[File, Folder, Project] ) -> None: - """Helper to verify that permissions have been deleted (entity inherits from parent).""" - for attempt in range(self.verification_attempts): - await asyncio.sleep(random.randint(1, 5)) + """Helper to verify that permissions have been deleted.""" + async def check(): acl = await entity.get_acl_async( principal_id=AUTHENTICATED_USERS, check_benefactor=False, synapse_client=self.syn, ) + return not acl - if not acl: - return # Verification successful - - if attempt == self.verification_attempts - 1: # Last attempt - assert not acl, ( - f"Permissions should be deleted, but they still exist on " - f"[id: {entity.id}, name: {entity.name}, {entity.__class__}]." - ) + await wait_for_condition( + condition_fn=check, + timeout_seconds=15, + poll_interval_seconds=1, + backoff_factor=1.5, + description=f"permissions deleted on {entity.id}", + ) async def _verify_permissions_not_deleted( self, entity: Union[File, Folder, Project] ) -> bool: """Helper to verify that permissions are still set on an entity.""" - for attempt in range(self.verification_attempts): - await asyncio.sleep(random.randint(1, 5)) + + async def check(): acl = await entity.get_acl_async( principal_id=AUTHENTICATED_USERS, check_benefactor=False, synapse_client=self.syn, ) - if "READ" in acl: - return True - - if attempt == self.verification_attempts - 1: # Last attempt - assert "READ" in acl + return "READ" in acl if acl else False - return True + result = await wait_for_condition( + condition_fn=check, + timeout_seconds=15, + poll_interval_seconds=1, + backoff_factor=1.5, + description=f"permissions still present on {entity.id}", + ) + return result async def _verify_list_acl_functionality( self, @@ -1042,8 +1050,8 @@ async def _verify_list_acl_functionality( log_tree: bool = True, ) -> AclListResult: """Helper to verify list_acl_async functionality and return results.""" - for attempt in range(self.verification_attempts): - await asyncio.sleep(random.randint(1, 5)) + + async def check(): acl_result = await entity.list_acl_async( recursive=recursive, include_container_content=include_container_content, @@ -1051,18 +1059,21 @@ async def _verify_list_acl_functionality( log_tree=log_tree, synapse_client=self.syn_with_logger, ) - if ( isinstance(acl_result, AclListResult) and len(acl_result.all_entity_acls) >= expected_entity_count ): return acl_result + return None - if attempt == self.verification_attempts - 1: # Last attempt - assert isinstance(acl_result, AclListResult) - assert len(acl_result.all_entity_acls) >= expected_entity_count - - return acl_result + result = await wait_for_condition( + condition_fn=check, + timeout_seconds=15, + poll_interval_seconds=1, + backoff_factor=1.5, + description=f"list_acl with >= {expected_entity_count} entities on {entity.id}", + ) + return result def _verify_log_messages( self, @@ -1416,7 +1427,7 @@ async def test_delete_permissions_on_new_project( # AND custom permissions are set for authenticated users await self._set_custom_permissions(project) - await asyncio.sleep(random.randint(1, 5)) + await asyncio.sleep(1) # WHEN I delete permissions on the project await project.delete_permissions_async(synapse_client=self.syn) @@ -1435,14 +1446,11 @@ async def test_delete_permissions_on_new_project( assert await self._verify_permissions_not_deleted(project) async def test_delete_permissions_simple_tree_structure( - self, project_object: Project, caplog: pytest.LogCaptureFixture + self, stored_project: Project, caplog: pytest.LogCaptureFixture ) -> None: """Test deleting permissions on a simple tree structure.""" - await project_object.store_async(synapse_client=self.syn) - self.schedule_for_cleanup(project_object.id) - # GIVEN a simple tree structure with permissions - structure = await self.create_simple_tree_structure(project_object) + structure = await self.create_simple_tree_structure(stored_project) folder_a = structure["folder_a"] file_1 = structure["file_1"] @@ -1451,7 +1459,7 @@ async def test_delete_permissions_simple_tree_structure( self._set_custom_permissions(folder_a), self._set_custom_permissions(file_1), ) - await asyncio.sleep(random.randint(1, 5)) + await asyncio.sleep(1) # WHEN - Verify list_acl_async before deletion await self._verify_list_acl_functionality( @@ -1481,20 +1489,17 @@ async def test_delete_permissions_simple_tree_structure( ) async def test_delete_permissions_deep_nested_structure( - self, project_object: Project, caplog: pytest.LogCaptureFixture + self, stored_project: Project, caplog: pytest.LogCaptureFixture ) -> None: """Test deleting permissions on a deeply nested structure.""" - await project_object.store_async(synapse_client=self.syn) - self.schedule_for_cleanup(project_object.id) - # GIVEN a deeply nested structure with permissions - structure = await self.create_deep_nested_structure(project_object) + structure = await self.create_deep_nested_structure(stored_project) # Set permissions on all entities await asyncio.gather( *[self._set_custom_permissions(entity) for entity in structure.values()] ) - await asyncio.sleep(random.randint(1, 5)) + await asyncio.sleep(1) # WHEN - Verify list_acl_async before deletion await self._verify_list_acl_functionality( @@ -1523,14 +1528,11 @@ async def test_delete_permissions_deep_nested_structure( ) async def test_delete_permissions_wide_tree_structure( - self, project_object: Project, caplog: pytest.LogCaptureFixture + self, stored_project: Project, caplog: pytest.LogCaptureFixture ) -> None: """Test deleting permissions on a wide tree structure with multiple siblings.""" - await project_object.store_async(synapse_client=self.syn) - self.schedule_for_cleanup(project_object.id) - # GIVEN a wide tree structure with permissions - structure = await self.create_wide_tree_structure(project_object) + structure = await self.create_wide_tree_structure(stored_project) folders = structure["folders"] all_files = structure["all_files"] root_file = structure["root_file"] @@ -1540,11 +1542,11 @@ async def test_delete_permissions_wide_tree_structure( await asyncio.gather( *[self._set_custom_permissions(entity) for entity in entities_to_set] ) - await asyncio.sleep(random.randint(1, 5)) + await asyncio.sleep(1) # WHEN - Verify list_acl_async before deletion await self._verify_list_acl_functionality( - entity=project_object, + entity=stored_project, expected_entity_count=7, # 3 folders + 3 files + 1 root file recursive=True, include_container_content=True, @@ -1556,7 +1558,7 @@ async def test_delete_permissions_wide_tree_structure( caplog.clear() # WHEN I delete permissions recursively from the project - await project_object.delete_permissions_async( + await stored_project.delete_permissions_async( recursive=True, include_container_content=True, dry_run=False, @@ -1570,14 +1572,11 @@ async def test_delete_permissions_wide_tree_structure( ) async def test_delete_permissions_complex_mixed_structure( - self, project_object: Project, caplog: pytest.LogCaptureFixture + self, stored_project: Project, caplog: pytest.LogCaptureFixture ) -> None: """Test deleting permissions on a complex mixed structure.""" - await project_object.store_async(synapse_client=self.syn) - self.schedule_for_cleanup(project_object.id) - # GIVEN a complex mixed structure with permissions - structure = await self.create_complex_mixed_structure(project_object) + structure = await self.create_complex_mixed_structure(stored_project) # Set permissions on all entities entities_to_set = ( @@ -1597,11 +1596,11 @@ async def test_delete_permissions_complex_mixed_structure( await asyncio.gather( *[self._set_custom_permissions(entity) for entity in entities_to_set] ) - await asyncio.sleep(random.randint(1, 5)) + await asyncio.sleep(1) # WHEN - Verify list_acl_functionality before deletion await self._verify_list_acl_functionality( - entity=project_object, + entity=stored_project, expected_entity_count=12, # complex structure with multiple entities recursive=True, include_container_content=True, @@ -1613,7 +1612,7 @@ async def test_delete_permissions_complex_mixed_structure( caplog.clear() # WHEN I delete permissions recursively from the project - await project_object.delete_permissions_async( + await stored_project.delete_permissions_async( recursive=True, include_container_content=True, dry_run=False, @@ -1627,19 +1626,16 @@ async def test_delete_permissions_complex_mixed_structure( # Edge case tests async def test_delete_permissions_empty_folder( - self, project_object: Project, caplog: pytest.LogCaptureFixture + self, stored_project: Project, caplog: pytest.LogCaptureFixture ) -> None: """Test deleting permissions on an empty folder.""" - await project_object.store_async(synapse_client=self.syn) - self.schedule_for_cleanup(project_object.id) - # GIVEN an empty folder with custom permissions empty_folder = await Folder(name=f"empty_folder_{uuid.uuid4()}").store_async( - parent=project_object, synapse_client=self.syn + parent=stored_project, synapse_client=self.syn ) self.schedule_for_cleanup(empty_folder.id) await self._set_custom_permissions(empty_folder) - await asyncio.sleep(random.randint(1, 5)) + await asyncio.sleep(1) # WHEN - Verify list_acl_async before deletion (empty folder) await self._verify_list_acl_functionality( @@ -1666,15 +1662,12 @@ async def test_delete_permissions_empty_folder( await self._verify_permissions_deleted(empty_folder) async def test_delete_permissions_folder_with_only_files( - self, project_object: Project, caplog: pytest.LogCaptureFixture + self, stored_project: Project, caplog: pytest.LogCaptureFixture ) -> None: """Test deleting permissions on a folder that contains only files.""" - await project_object.store_async(synapse_client=self.syn) - self.schedule_for_cleanup(project_object.id) - # GIVEN a folder with only one file folder = await Folder(name=f"files_only_folder_{uuid.uuid4()}").store_async( - parent=project_object, synapse_client=self.syn + parent=stored_project, synapse_client=self.syn ) self.schedule_for_cleanup(folder.id) @@ -1688,7 +1681,7 @@ async def test_delete_permissions_folder_with_only_files( self._set_custom_permissions(folder), self._set_custom_permissions(file), ) - await asyncio.sleep(random.randint(1, 5)) + await asyncio.sleep(1) # WHEN - Verify list_acl_async before deletion await self._verify_list_acl_functionality( @@ -1718,16 +1711,13 @@ async def test_delete_permissions_folder_with_only_files( ) async def test_delete_permissions_folder_with_only_folders( - self, project_object: Project, caplog: pytest.LogCaptureFixture + self, stored_project: Project, caplog: pytest.LogCaptureFixture ) -> None: """Test deleting permissions on a folder that contains only sub-folders.""" - await project_object.store_async(synapse_client=self.syn) - self.schedule_for_cleanup(project_object.id) - # GIVEN a folder with only sub-folders parent_folder = await Folder( name=f"folders_only_parent_{uuid.uuid4()}" - ).store_async(parent=project_object, synapse_client=self.syn) + ).store_async(parent=stored_project, synapse_client=self.syn) self.schedule_for_cleanup(parent_folder.id) # Create sub-folders in parallel @@ -1748,7 +1738,7 @@ async def test_delete_permissions_folder_with_only_folders( await asyncio.gather( *[self._set_custom_permissions(entity) for entity in entities_to_set] ) - await asyncio.sleep(random.randint(1, 5)) + await asyncio.sleep(1) # WHEN - Verify list_acl_async before deletion await self._verify_list_acl_functionality( @@ -1777,14 +1767,11 @@ async def test_delete_permissions_folder_with_only_folders( ) async def test_delete_permissions_target_files_only_complex( - self, project_object: Project, caplog: pytest.LogCaptureFixture + self, stored_project: Project, caplog: pytest.LogCaptureFixture ) -> None: """Test deleting permissions targeting only files in a complex structure.""" - await project_object.store_async(synapse_client=self.syn) - self.schedule_for_cleanup(project_object.id) - # GIVEN a complex structure with permissions - structure = await self.create_complex_mixed_structure(project_object) + structure = await self.create_complex_mixed_structure(stored_project) # Set permissions on all entities await asyncio.gather( @@ -1794,11 +1781,11 @@ async def test_delete_permissions_target_files_only_complex( self._set_custom_permissions(structure["sub_deep"]), *[self._set_custom_permissions(file) for file in structure["deep_files"]], ) - await asyncio.sleep(random.randint(1, 5)) + await asyncio.sleep(1) # WHEN - Verify list_acl_async with target_entity_types for files only await self._verify_list_acl_functionality( - entity=project_object, + entity=stored_project, expected_entity_count=4, # shallow_file + 3 deep_files recursive=True, include_container_content=True, @@ -1811,7 +1798,7 @@ async def test_delete_permissions_target_files_only_complex( caplog.clear() # WHEN I delete permissions targeting only files - await project_object.delete_permissions_async( + await stored_project.delete_permissions_async( recursive=True, include_container_content=True, target_entity_types=["file"], @@ -1837,20 +1824,17 @@ async def test_delete_permissions_target_files_only_complex( # Include container content vs recursive tests async def test_delete_permissions_include_container_only_deep_structure( - self, project_object: Project, caplog: pytest.LogCaptureFixture + self, stored_project: Project, caplog: pytest.LogCaptureFixture ) -> None: """Test include_container_content=True without recursive on deep structure.""" - await project_object.store_async(synapse_client=self.syn) - self.schedule_for_cleanup(project_object.id) - # GIVEN a deep nested structure with permissions - structure = await self.create_deep_nested_structure(project_object) + structure = await self.create_deep_nested_structure(stored_project) # Set permissions on all entities await asyncio.gather( *[self._set_custom_permissions(entity) for entity in structure.values()] ) - await asyncio.sleep(random.randint(1, 5)) + await asyncio.sleep(1) # WHEN - Verify list_acl_async with include_container_content=True await self._verify_list_acl_functionality( @@ -1891,14 +1875,11 @@ async def test_delete_permissions_include_container_only_deep_structure( ) async def test_delete_permissions_skip_self_complex_structure( - self, project_object: Project, caplog: pytest.LogCaptureFixture + self, stored_project: Project, caplog: pytest.LogCaptureFixture ) -> None: """Test include_self=False on a complex structure.""" - await project_object.store_async(synapse_client=self.syn) - self.schedule_for_cleanup(project_object.id) - # GIVEN a complex mixed structure with permissions - structure = await self.create_complex_mixed_structure(project_object) + structure = await self.create_complex_mixed_structure(stored_project) # Set permissions on all entities await asyncio.gather( @@ -1909,7 +1890,7 @@ async def test_delete_permissions_skip_self_complex_structure( ], *[self._set_custom_permissions(file) for file in structure["mixed_files"]], ) - await asyncio.sleep(random.randint(1, 5)) + await asyncio.sleep(1) # WHEN - Verify list_acl_async before deletion (should show all entities) await self._verify_list_acl_functionality( @@ -1950,14 +1931,11 @@ async def test_delete_permissions_skip_self_complex_structure( # Dry run functionality tests async def test_delete_permissions_dry_run_no_changes( - self, project_object: Project, caplog: pytest.LogCaptureFixture + self, stored_project: Project, caplog: pytest.LogCaptureFixture ) -> None: """Test that dry_run=True makes no actual changes.""" - await project_object.store_async(synapse_client=self.syn) - self.schedule_for_cleanup(project_object.id) - # GIVEN a simple structure with permissions - structure = await self.create_simple_tree_structure(project_object) + structure = await self.create_simple_tree_structure(stored_project) folder_a = structure["folder_a"] file_1 = structure["file_1"] @@ -1966,7 +1944,7 @@ async def test_delete_permissions_dry_run_no_changes( self._set_custom_permissions(folder_a), self._set_custom_permissions(file_1), ) - await asyncio.sleep(random.randint(1, 5)) + await asyncio.sleep(1) # WHEN - Verify list_acl_async before dry run initial_acl_result = await self._verify_list_acl_functionality( @@ -2020,14 +1998,11 @@ async def test_delete_permissions_dry_run_no_changes( ) async def test_delete_permissions_dry_run_complex_logging( - self, project_object: Project, caplog: pytest.LogCaptureFixture + self, stored_project: Project, caplog: pytest.LogCaptureFixture ) -> None: """Test dry run logging for complex structures.""" - await project_object.store_async(synapse_client=self.syn) - self.schedule_for_cleanup(project_object.id) - # GIVEN a complex structure with permissions - structure = await self.create_complex_mixed_structure(project_object) + structure = await self.create_complex_mixed_structure(stored_project) # Set permissions on a subset of entities await asyncio.gather( @@ -2035,7 +2010,7 @@ async def test_delete_permissions_dry_run_complex_logging( self._set_custom_permissions(structure["sub_deep"]), self._set_custom_permissions(structure["deep_files"][0]), ) - await asyncio.sleep(random.randint(1, 5)) + await asyncio.sleep(1) # WHEN - Verify list_acl_async with detailed logging before dry run await self._verify_list_acl_functionality( @@ -2082,15 +2057,12 @@ async def test_delete_permissions_dry_run_complex_logging( # Performance and stress tests async def test_delete_permissions_large_flat_structure( - self, project_object: Project, caplog: pytest.LogCaptureFixture + self, stored_project: Project, caplog: pytest.LogCaptureFixture ) -> None: """Test deleting permissions on a large flat structure.""" - await project_object.store_async(synapse_client=self.syn) - self.schedule_for_cleanup(project_object.id) - # GIVEN a folder with many files large_folder = await Folder(name=f"large_folder_{uuid.uuid4()}").store_async( - parent=project_object, synapse_client=self.syn + parent=stored_project, synapse_client=self.syn ) self.schedule_for_cleanup(large_folder.id) @@ -2112,7 +2084,7 @@ async def test_delete_permissions_large_flat_structure( await asyncio.gather( *[self._set_custom_permissions(entity) for entity in entities_to_set] ) - await asyncio.sleep(random.randint(1, 5)) + await asyncio.sleep(1) # WHEN - Verify list_acl_async performance with large structure await self._verify_list_acl_functionality( @@ -2141,16 +2113,13 @@ async def test_delete_permissions_large_flat_structure( ) async def test_delete_permissions_multiple_nested_branches( - self, project_object: Project, caplog: pytest.LogCaptureFixture + self, stored_project: Project, caplog: pytest.LogCaptureFixture ) -> None: """Test deleting permissions on multiple nested branches simultaneously.""" - await project_object.store_async(synapse_client=self.syn) - self.schedule_for_cleanup(project_object.id) - # GIVEN multiple complex nested branches branch_tasks = [ Folder(name=f"branch_{branch_name}_{uuid.uuid4()}").store_async( - parent=project_object, synapse_client=self.syn + parent=stored_project, synapse_client=self.syn ) for branch_name in ["alpha", "beta"] ] @@ -2207,11 +2176,11 @@ async def test_delete_permissions_multiple_nested_branches( await asyncio.gather( *[self._set_custom_permissions(entity) for entity in all_entities] ) - await asyncio.sleep(random.randint(1, 5)) + await asyncio.sleep(1) # WHEN - Verify list_acl_async before deletion (complex multiple branches) await self._verify_list_acl_functionality( - entity=project_object, + entity=stored_project, expected_entity_count=11, recursive=True, include_container_content=True, @@ -2223,7 +2192,7 @@ async def test_delete_permissions_multiple_nested_branches( caplog.clear() # WHEN I delete permissions recursively from the project - await project_object.delete_permissions_async( + await stored_project.delete_permissions_async( recursive=True, include_container_content=True, dry_run=False, @@ -2236,20 +2205,17 @@ async def test_delete_permissions_multiple_nested_branches( ) async def test_delete_permissions_selective_branches( - self, project_object: Project, caplog: pytest.LogCaptureFixture + self, stored_project: Project, caplog: pytest.LogCaptureFixture ) -> None: """Test selectively deleting permissions from specific branches.""" - await project_object.store_async(synapse_client=self.syn) - self.schedule_for_cleanup(project_object.id) - # GIVEN multiple branches with permissions # Create branches in parallel branch_tasks = [ Folder(name=f"branch_a_{uuid.uuid4()}").store_async( - parent=project_object, synapse_client=self.syn + parent=stored_project, synapse_client=self.syn ), Folder(name=f"branch_b_{uuid.uuid4()}").store_async( - parent=project_object, synapse_client=self.syn + parent=stored_project, synapse_client=self.syn ), ] branch_a, branch_b = await asyncio.gather(*branch_tasks) @@ -2280,7 +2246,7 @@ async def test_delete_permissions_selective_branches( self._set_custom_permissions(file_a), self._set_custom_permissions(file_b), ) - await asyncio.sleep(random.randint(1, 5)) + await asyncio.sleep(1) # WHEN - Verify list_acl_async before selective deletion await self._verify_list_acl_functionality( @@ -2316,14 +2282,11 @@ async def test_delete_permissions_selective_branches( ) async def test_delete_permissions_mixed_entity_types_in_structure( - self, project_object: Project, caplog: pytest.LogCaptureFixture + self, stored_project: Project, caplog: pytest.LogCaptureFixture ) -> None: """Test deleting permissions with mixed entity types in complex structure.""" - await project_object.store_async(synapse_client=self.syn) - self.schedule_for_cleanup(project_object.id) - # GIVEN a structure with both files and folders at multiple levels - structure = await self.create_complex_mixed_structure(project_object) + structure = await self.create_complex_mixed_structure(stored_project) # Set permissions on a mix of entities await asyncio.gather( @@ -2333,11 +2296,11 @@ async def test_delete_permissions_mixed_entity_types_in_structure( self._set_custom_permissions(structure["deep_files"][1]), self._set_custom_permissions(structure["mixed_sub_folders"][0]), ) - await asyncio.sleep(random.randint(1, 5)) + await asyncio.sleep(1) # WHEN - Verify list_acl_async with mixed entity types await self._verify_list_acl_functionality( - entity=project_object, + entity=stored_project, expected_entity_count=5, # All the entities we set permissions on recursive=True, include_container_content=True, @@ -2350,7 +2313,7 @@ async def test_delete_permissions_mixed_entity_types_in_structure( caplog.clear() # WHEN I delete permissions targeting both files and folders - await project_object.delete_permissions_async( + await stored_project.delete_permissions_async( recursive=True, include_container_content=True, target_entity_types=["file", "folder"], @@ -2368,15 +2331,12 @@ async def test_delete_permissions_mixed_entity_types_in_structure( ) async def test_delete_permissions_no_container_content_but_has_children( - self, project_object: Project, caplog: pytest.LogCaptureFixture + self, stored_project: Project, caplog: pytest.LogCaptureFixture ) -> None: """Test deleting permissions without include_container_content when children exist.""" - await project_object.store_async(synapse_client=self.syn) - self.schedule_for_cleanup(project_object.id) - # GIVEN a folder with children and custom permissions parent_folder = await Folder(name=f"parent_folder_{uuid.uuid4()}").store_async( - parent=project_object, synapse_client=self.syn + parent=stored_project, synapse_client=self.syn ) self.schedule_for_cleanup(parent_folder.id) @@ -2390,7 +2350,7 @@ async def test_delete_permissions_no_container_content_but_has_children( self._set_custom_permissions(parent_folder), self._set_custom_permissions(child_file), ) - await asyncio.sleep(random.randint(1, 5)) + await asyncio.sleep(1) # WHEN - Verify list_acl_async before testing container content exclusion await self._verify_list_acl_functionality( @@ -2420,14 +2380,11 @@ async def test_delete_permissions_no_container_content_but_has_children( await self._verify_permissions_not_deleted(child_file) async def test_delete_permissions_case_insensitive_entity_types( - self, project_object: Project, caplog: pytest.LogCaptureFixture + self, stored_project: Project, caplog: pytest.LogCaptureFixture ) -> None: """Test that target_entity_types are case-insensitive.""" - await project_object.store_async(synapse_client=self.syn) - self.schedule_for_cleanup(project_object.id) - # GIVEN a simple structure with permissions - structure = await self.create_simple_tree_structure(project_object) + structure = await self.create_simple_tree_structure(stored_project) folder_a = structure["folder_a"] file_1 = structure["file_1"] @@ -2436,7 +2393,7 @@ async def test_delete_permissions_case_insensitive_entity_types( self._set_custom_permissions(folder_a), self._set_custom_permissions(file_1), ) - await asyncio.sleep(random.randint(1, 5)) + await asyncio.sleep(1) # WHEN - Verify list_acl_async with case-insensitive entity types await self._verify_list_acl_functionality( @@ -2589,7 +2546,7 @@ async def create_all_entity_types_with_acl( synapse_client=self.syn, ) - await asyncio.sleep(10) + await asyncio.sleep(2) return entities async def test_list_acl_async_all_entity_types(self) -> None: diff --git a/tests/integration/synapseclient/models/async/test_recordset_async.py b/tests/integration/synapseclient/models/async/test_recordset_async.py index 4a3e58e20..191ff360b 100644 --- a/tests/integration/synapseclient/models/async/test_recordset_async.py +++ b/tests/integration/synapseclient/models/async/test_recordset_async.py @@ -513,7 +513,7 @@ async def record_set_with_validation_fixture( self.schedule_for_cleanup(stored_record_set.id) record_set_ids.append(stored_record_set.id) # Track for schema cleanup - await asyncio.sleep(10) + await asyncio.sleep(3) # Bind the JSON schema to the RecordSet await stored_record_set.bind_schema_async( @@ -526,7 +526,7 @@ async def record_set_with_validation_fixture( await stored_record_set.get_schema_async(synapse_client=self.syn) # Wait for schema binding to be fully processed by backend - await asyncio.sleep(10) + await asyncio.sleep(5) # Create a Grid session from the RecordSet grid = Grid(record_set_id=stored_record_set.id) @@ -534,7 +534,7 @@ async def record_set_with_validation_fixture( timeout=ASYNC_JOB_TIMEOUT_SEC, synapse_client=self.syn ) - await asyncio.sleep(10) + await asyncio.sleep(3) # Export the Grid back to RecordSet to generate validation results exported_grid = await created_grid.export_to_record_set_async( diff --git a/tests/integration/synapseclient/models/async/test_schema_organization_async.py b/tests/integration/synapseclient/models/async/test_schema_organization_async.py index 72481c7f0..f5ec1ba66 100644 --- a/tests/integration/synapseclient/models/async/test_schema_organization_async.py +++ b/tests/integration/synapseclient/models/async/test_schema_organization_async.py @@ -140,7 +140,7 @@ async def test_create_and_get(self, organization: SchemaOrganization) -> None: assert not exists # WHEN I store the organization the metadata will be saved await organization.store_async(synapse_client=self.syn) - await asyncio.sleep(10) + await asyncio.sleep(3) assert organization.name is not None assert organization.id is not None assert organization.created_by is not None @@ -167,7 +167,7 @@ async def test_get_json_schemas_async( ) -> None: # GIVEN an organization with no schemas and one with 3 schemas await organization.store_async(synapse_client=self.syn) - await asyncio.sleep(10) + await asyncio.sleep(3) # THEN get_json_schema_list should return the correct list of schemas schema_list = [] async for item in organization.get_json_schemas_async(synapse_client=self.syn): @@ -191,7 +191,7 @@ async def test_get_acl_and_update_acl( await organization.get_acl_async(synapse_client=self.syn) # GIVEN an organization that has been created await organization.store_async(synapse_client=self.syn) - await asyncio.sleep(10) + await asyncio.sleep(3) acl = await organization.get_acl_async(synapse_client=self.syn) resource_access: list[dict[str, Any]] = acl["resourceAccess"] # THEN the resource access should be have one principal diff --git a/tests/integration/synapseclient/models/async/test_submission_async.py b/tests/integration/synapseclient/models/async/test_submission_async.py index 6c75c1882..f8ae95f9c 100644 --- a/tests/integration/synapseclient/models/async/test_submission_async.py +++ b/tests/integration/synapseclient/models/async/test_submission_async.py @@ -17,7 +17,7 @@ def init(self, syn: Synapse, schedule_for_cleanup: Callable[..., None]) -> None: self.syn = syn self.schedule_for_cleanup = schedule_for_cleanup - @pytest_asyncio.fixture(scope="function") + @pytest_asyncio.fixture(scope="class") async def test_project( self, syn: Synapse, schedule_for_cleanup: Callable[..., None] ) -> Project: @@ -28,7 +28,7 @@ async def test_project( schedule_for_cleanup(project.id) return project - @pytest_asyncio.fixture(scope="function") + @pytest_asyncio.fixture(scope="class") async def test_evaluation( self, test_project: Project, @@ -131,7 +131,7 @@ def init(self, syn: Synapse, schedule_for_cleanup: Callable[..., None]) -> None: self.syn = syn self.schedule_for_cleanup = schedule_for_cleanup - @pytest_asyncio.fixture(scope="function") + @pytest_asyncio.fixture(scope="class") async def test_project( self, syn: Synapse, schedule_for_cleanup: Callable[..., None] ) -> Project: @@ -142,7 +142,7 @@ async def test_project( schedule_for_cleanup(project.id) return project - @pytest_asyncio.fixture(scope="function") + @pytest_asyncio.fixture(scope="class") async def test_evaluation( self, test_project: Project, @@ -305,7 +305,7 @@ def init(self, syn: Synapse, schedule_for_cleanup: Callable[..., None]) -> None: self.syn = syn self.schedule_for_cleanup = schedule_for_cleanup - @pytest_asyncio.fixture(scope="function") + @pytest_asyncio.fixture(scope="class") async def test_project( self, syn: Synapse, schedule_for_cleanup: Callable[..., None] ) -> Project: @@ -316,7 +316,7 @@ async def test_project( schedule_for_cleanup(project.id) return project - @pytest_asyncio.fixture(scope="function") + @pytest_asyncio.fixture(scope="class") async def test_evaluation( self, test_project: Project, @@ -398,7 +398,7 @@ def init(self, syn: Synapse, schedule_for_cleanup: Callable[..., None]) -> None: self.syn = syn self.schedule_for_cleanup = schedule_for_cleanup - @pytest_asyncio.fixture(scope="function") + @pytest_asyncio.fixture(scope="class") async def test_project( self, syn: Synapse, schedule_for_cleanup: Callable[..., None] ) -> Project: @@ -409,7 +409,7 @@ async def test_project( schedule_for_cleanup(project.id) return project - @pytest_asyncio.fixture(scope="function") + @pytest_asyncio.fixture(scope="class") async def test_evaluation( self, test_project: Project, diff --git a/tests/integration/synapseclient/models/async/test_submission_bundle_async.py b/tests/integration/synapseclient/models/async/test_submission_bundle_async.py index 2cbfc081c..b186d4ea6 100644 --- a/tests/integration/synapseclient/models/async/test_submission_bundle_async.py +++ b/tests/integration/synapseclient/models/async/test_submission_bundle_async.py @@ -25,7 +25,7 @@ def init(self, syn: Synapse, schedule_for_cleanup: Callable[..., None]) -> None: self.syn = syn self.schedule_for_cleanup = schedule_for_cleanup - @pytest.fixture(scope="function") + @pytest.fixture(scope="class") async def test_project( self, syn: Synapse, schedule_for_cleanup: Callable[..., None] ) -> Project: @@ -35,7 +35,7 @@ async def test_project( schedule_for_cleanup(project.id) return project - @pytest.fixture(scope="function") + @pytest.fixture(scope="class") async def test_evaluation( self, test_project: Project, @@ -316,7 +316,7 @@ def init(self, syn: Synapse, schedule_for_cleanup: Callable[..., None]) -> None: self.syn = syn self.schedule_for_cleanup = schedule_for_cleanup - @pytest.fixture(scope="function") + @pytest.fixture(scope="class") async def test_project( self, syn: Synapse, schedule_for_cleanup: Callable[..., None] ) -> Project: @@ -326,7 +326,7 @@ async def test_project( schedule_for_cleanup(project.id) return project - @pytest.fixture(scope="function") + @pytest.fixture(scope="class") async def test_evaluation( self, test_project: Project, @@ -494,7 +494,7 @@ def init(self, syn: Synapse, schedule_for_cleanup: Callable[..., None]) -> None: self.syn = syn self.schedule_for_cleanup = schedule_for_cleanup - @pytest.fixture(scope="function") + @pytest.fixture(scope="class") async def test_project( self, syn: Synapse, schedule_for_cleanup: Callable[..., None] ) -> Project: @@ -504,7 +504,7 @@ async def test_project( schedule_for_cleanup(project.id) return project - @pytest.fixture(scope="function") + @pytest.fixture(scope="class") async def test_evaluation( self, test_project: Project, diff --git a/tests/integration/synapseclient/models/async/test_submission_status_async.py b/tests/integration/synapseclient/models/async/test_submission_status_async.py index 7ba51fd62..f345fa2e8 100644 --- a/tests/integration/synapseclient/models/async/test_submission_status_async.py +++ b/tests/integration/synapseclient/models/async/test_submission_status_async.py @@ -34,7 +34,7 @@ def init(self, syn: Synapse, schedule_for_cleanup: Callable[..., None]) -> None: self.syn = syn self.schedule_for_cleanup = schedule_for_cleanup - @pytest.fixture(scope="function") + @pytest.fixture(scope="class") async def test_evaluation( self, project_model: Project, @@ -53,7 +53,7 @@ async def test_evaluation( schedule_for_cleanup(created_evaluation.id) return created_evaluation - @pytest.fixture(scope="function") + @pytest.fixture(scope="class") async def test_file( self, project_model: Project, @@ -145,7 +145,7 @@ def init(self, syn: Synapse, schedule_for_cleanup: Callable[..., None]) -> None: self.syn = syn self.schedule_for_cleanup = schedule_for_cleanup - @pytest.fixture(scope="function") + @pytest.fixture(scope="class") async def test_evaluation( self, project_model: Project, @@ -164,7 +164,7 @@ async def test_evaluation( schedule_for_cleanup(created_evaluation.id) return created_evaluation - @pytest.fixture(scope="function") + @pytest.fixture(scope="class") async def test_file( self, project_model: Project, @@ -490,7 +490,7 @@ def init(self, syn: Synapse, schedule_for_cleanup: Callable[..., None]) -> None: self.syn = syn self.schedule_for_cleanup = schedule_for_cleanup - @pytest.fixture(scope="function") + @pytest.fixture(scope="class") async def test_evaluation( self, project_model: Project, @@ -509,7 +509,7 @@ async def test_evaluation( schedule_for_cleanup(created_evaluation.id) return created_evaluation - @pytest.fixture(scope="function") + @pytest.fixture(scope="class") async def test_files( self, project_model: Project, @@ -678,7 +678,7 @@ def init(self, syn: Synapse, schedule_for_cleanup: Callable[..., None]) -> None: self.syn = syn self.schedule_for_cleanup = schedule_for_cleanup - @pytest.fixture(scope="function") + @pytest.fixture(scope="class") async def test_evaluation( self, project_model: Project, @@ -697,7 +697,7 @@ async def test_evaluation( schedule_for_cleanup(created_evaluation.id) return created_evaluation - @pytest.fixture(scope="function") + @pytest.fixture(scope="class") async def test_file( self, project_model: Project, diff --git a/tests/integration/synapseclient/models/async/test_table_async.py b/tests/integration/synapseclient/models/async/test_table_async.py index b2d49e2ca..db88116ac 100644 --- a/tests/integration/synapseclient/models/async/test_table_async.py +++ b/tests/integration/synapseclient/models/async/test_table_async.py @@ -2179,13 +2179,14 @@ async def test_delete_column(self, project_model: Project) -> None: class TestQuerying: - @pytest.fixture(autouse=True, scope="function") - def init(self, syn: Synapse, schedule_for_cleanup: Callable[..., None]) -> None: - self.syn = syn - self.schedule_for_cleanup = schedule_for_cleanup - - async def test_query_to_csv(self, project_model: Project) -> None: - # GIVEN a table with a column defined + @pytest.fixture(scope="class") + async def populated_table( + self, + project_model: Project, + syn: Synapse, + schedule_for_cleanup: Callable[..., None], + ): + """Class-scoped fixture providing a table with test data for query tests.""" table_name = str(uuid.uuid4()) table = Table( name=table_name, @@ -2196,11 +2197,10 @@ async def test_query_to_csv(self, project_model: Project) -> None: Column(name="float_column", column_type=ColumnType.DOUBLE), ], ) - table = await table.store_async(synapse_client=self.syn) - self.schedule_for_cleanup(table.id) + table = await table.store_async(synapse_client=syn) + schedule_for_cleanup(table.id) - # AND data for the table stored in synapse - data_for_table = pd.DataFrame( + data = pd.DataFrame( { "column_string": ["value1", "value2", "value3", "value4"], "integer_column": [1, 2, 3, None], @@ -2208,14 +2208,22 @@ async def test_query_to_csv(self, project_model: Project) -> None: } ) await table.store_rows_async( - values=data_for_table, schema_storage_strategy=None, synapse_client=self.syn + values=data, schema_storage_strategy=None, synapse_client=syn ) + return table, data + + async def test_query_to_csv( + self, project_model: Project, populated_table, syn: Synapse + ) -> None: + # GIVEN a table with data already stored in synapse + table, data = populated_table + # WHEN I query the table with a temporary directory with tempfile.TemporaryDirectory() as temp_dir_name: results = await query_async( query=f"SELECT * FROM {table.id}", - synapse_client=self.syn, + synapse_client=syn, download_location=temp_dir_name, ) # THEN The returned result should be a path to the CSV @@ -2225,41 +2233,20 @@ async def test_query_to_csv(self, project_model: Project) -> None: # AND the data in the columns should match pd.testing.assert_series_equal( - as_dataframe["column_string"], data_for_table["column_string"] + as_dataframe["column_string"], data["column_string"] ) pd.testing.assert_series_equal( - as_dataframe["integer_column"], data_for_table["integer_column"] + as_dataframe["integer_column"], data["integer_column"] ) pd.testing.assert_series_equal( - as_dataframe["float_column"], data_for_table["float_column"] + as_dataframe["float_column"], data["float_column"] ) - async def test_part_mask_query_everything(self, project_model: Project) -> None: - # GIVEN a table with a column defined - table_name = str(uuid.uuid4()) - table = Table( - name=table_name, - parent_id=project_model.id, - columns=[ - Column(name="column_string", column_type=ColumnType.STRING), - Column(name="integer_column", column_type=ColumnType.INTEGER), - Column(name="float_column", column_type=ColumnType.DOUBLE), - ], - ) - table = await table.store_async(synapse_client=self.syn) - self.schedule_for_cleanup(table.id) - - # AND data for the table stored in synapse - data_for_table = pd.DataFrame( - { - "column_string": ["value1", "value2", "value3", "value4"], - "integer_column": [1, 2, 3, None], - "float_column": [1.1, 2.2, 3.3, None], - } - ) - await table.store_rows_async( - values=data_for_table, schema_storage_strategy=None, synapse_client=self.syn - ) + async def test_part_mask_query_everything( + self, project_model: Project, populated_table, syn: Synapse + ) -> None: + # GIVEN a table with data already stored in synapse + table, data = populated_table # WHEN I query the table with a part mask QUERY_RESULTS = 0x1 @@ -2270,20 +2257,20 @@ async def test_part_mask_query_everything(self, project_model: Project) -> None: results = await query_part_mask_async( query=f"SELECT * FROM {table.id}", - synapse_client=self.syn, + synapse_client=syn, part_mask=part_mask, timeout=QUERY_TIMEOUT_SEC, ) # THEN the data in the columns should match pd.testing.assert_series_equal( - results.result["column_string"], data_for_table["column_string"] + results.result["column_string"], data["column_string"] ) pd.testing.assert_series_equal( - results.result["integer_column"], data_for_table["integer_column"] + results.result["integer_column"], data["integer_column"] ) pd.testing.assert_series_equal( - results.result["float_column"], data_for_table["float_column"] + results.result["float_column"], data["float_column"] ) # AND the part mask should be reflected in the results @@ -2293,51 +2280,30 @@ async def test_part_mask_query_everything(self, project_model: Project) -> None: assert results.sum_file_sizes.sum_file_size_bytes is not None assert results.last_updated_on is not None - async def test_part_mask_query_results_only(self, project_model: Project) -> None: - # GIVEN a table with a column defined - table_name = str(uuid.uuid4()) - table = Table( - name=table_name, - parent_id=project_model.id, - columns=[ - Column(name="column_string", column_type=ColumnType.STRING), - Column(name="integer_column", column_type=ColumnType.INTEGER), - Column(name="float_column", column_type=ColumnType.DOUBLE), - ], - ) - table = await table.store_async(synapse_client=self.syn) - self.schedule_for_cleanup(table.id) - - # AND data for the table stored in synapse - data_for_table = pd.DataFrame( - { - "column_string": ["value1", "value2", "value3", "value4"], - "integer_column": [1, 2, 3, None], - "float_column": [1.1, 2.2, 3.3, None], - } - ) - await table.store_rows_async( - values=data_for_table, schema_storage_strategy=None, synapse_client=self.syn - ) + async def test_part_mask_query_results_only( + self, project_model: Project, populated_table, syn: Synapse + ) -> None: + # GIVEN a table with data already stored in synapse + table, data = populated_table # WHEN I query the table with a part mask QUERY_RESULTS = 0x1 results = await query_part_mask_async( query=f"SELECT * FROM {table.id}", - synapse_client=self.syn, + synapse_client=syn, part_mask=QUERY_RESULTS, timeout=QUERY_TIMEOUT_SEC, ) # THEN the data in the columns should match pd.testing.assert_series_equal( - results.result["column_string"], data_for_table["column_string"] + results.result["column_string"], data["column_string"] ) pd.testing.assert_series_equal( - results.result["integer_column"], data_for_table["integer_column"] + results.result["integer_column"], data["integer_column"] ) pd.testing.assert_series_equal( - results.result["float_column"], data_for_table["float_column"] + results.result["float_column"], data["float_column"] ) # AND the part mask should be reflected in the results diff --git a/tests/integration/synapseclient/models/async/test_team_async.py b/tests/integration/synapseclient/models/async/test_team_async.py index 7398f3347..72af94e37 100644 --- a/tests/integration/synapseclient/models/async/test_team_async.py +++ b/tests/integration/synapseclient/models/async/test_team_async.py @@ -76,7 +76,7 @@ async def test_team_lifecycle(self) -> None: await self.verify_team_properties(from_id_team, test_team) # Name-based retrieval is eventually consistent, so we need to wait - await asyncio.sleep(10) + await asyncio.sleep(3) # WHEN I retrieve the team using a Team object with name name_team = Team(name=test_team.name) diff --git a/tests/integration/synapseclient/models/async/test_virtualtable_async.py b/tests/integration/synapseclient/models/async/test_virtualtable_async.py index 3006c18f6..8d051e6fe 100644 --- a/tests/integration/synapseclient/models/async/test_virtualtable_async.py +++ b/tests/integration/synapseclient/models/async/test_virtualtable_async.py @@ -154,8 +154,13 @@ def init(self, syn: Synapse, schedule_for_cleanup: Callable[..., None]) -> None: self.syn = syn self.schedule_for_cleanup = schedule_for_cleanup - @pytest.fixture(scope="function") - async def base_table_with_data(self, project_model: Project) -> Table: + @pytest.fixture(scope="class") + async def base_table_with_data( + self, + project_model: Project, + syn: Synapse, + schedule_for_cleanup: Callable[..., None], + ) -> Table: # Create a table with columns and data table_name = str(uuid.uuid4()) table = Table( @@ -167,8 +172,8 @@ async def base_table_with_data(self, project_model: Project) -> Table: Column(name="city", column_type=ColumnType.STRING), ], ) - table = await table.store_async(synapse_client=self.syn) - self.schedule_for_cleanup(table.id) + table = await table.store_async(synapse_client=syn) + schedule_for_cleanup(table.id) # Insert data into the table data = pd.DataFrame( @@ -178,7 +183,7 @@ async def base_table_with_data(self, project_model: Project) -> Table: "city": ["New York", "Boston", "Chicago"], } ) - await table.store_rows_async(data, synapse_client=self.syn) + await table.store_rows_async(data, synapse_client=syn) return table @@ -446,7 +451,7 @@ async def test_virtual_table_with_aggregation(self, project_model: Project) -> N self.schedule_for_cleanup(virtual_table.id) # Wait for virtual table to be ready - await asyncio.sleep(3) + await asyncio.sleep(2) # WHEN querying the aggregation virtual table query_result = await virtual_table.query_async( diff --git a/tests/integration/synapseclient/models/async/test_wiki_async.py b/tests/integration/synapseclient/models/async/test_wiki_async.py index bb1c695bd..971be086e 100644 --- a/tests/integration/synapseclient/models/async/test_wiki_async.py +++ b/tests/integration/synapseclient/models/async/test_wiki_async.py @@ -4,7 +4,6 @@ import gzip import os import tempfile -import time import uuid from typing import Callable @@ -20,33 +19,30 @@ WikiOrderHint, WikiPage, ) - - -@pytest.fixture(scope="function") -async def wiki_page_fixture( - syn: Synapse, schedule_for_cleanup: Callable[..., None] -) -> WikiPage: - """Create a root wiki page fixture that can be shared across tests.""" - # Create a new project for this test class - project = Project(name=f"Test Wiki Project_" + str(uuid.uuid4())) - project = await project.store_async(synapse_client=syn) - schedule_for_cleanup(project.id) - - wiki_title = f"Root Wiki Page {str(uuid.uuid4())}" - wiki_markdown = "# Root Wiki Page\n\nThis is a root wiki page." - - wiki_page = WikiPage( - owner_id=project.id, - title=wiki_title, - markdown=wiki_markdown, - ) - root_wiki = await wiki_page.store_async(synapse_client=syn) - return root_wiki +from tests.integration.helpers import wait_for_condition class TestWikiPageBasicOperations: """Tests for basic WikiPage CRUD operations.""" + @pytest.fixture(scope="class") + async def wiki_page_fixture( + self, syn: Synapse, schedule_for_cleanup: Callable[..., None] + ) -> WikiPage: + """Create a root wiki page fixture shared across tests in this class.""" + project = Project(name=f"Test Wiki Project_" + str(uuid.uuid4())) + project = await project.store_async(synapse_client=syn) + schedule_for_cleanup(project.id) + wiki_title = f"Root Wiki Page {str(uuid.uuid4())}" + wiki_markdown = "# Root Wiki Page\n\nThis is a root wiki page." + wiki_page = WikiPage( + owner_id=project.id, + title=wiki_title, + markdown=wiki_markdown, + ) + root_wiki = await wiki_page.store_async(synapse_client=syn) + return root_wiki + @pytest.fixture(autouse=True, scope="function") def init(self, syn: Synapse, schedule_for_cleanup: Callable[..., None]) -> None: self.syn = syn @@ -144,6 +140,24 @@ async def test_create_sub_wiki_page( class TestWikiPageAttachments: """Tests for WikiPage attachment operations.""" + @pytest.fixture(scope="class") + async def wiki_page_fixture( + self, syn: Synapse, schedule_for_cleanup: Callable[..., None] + ) -> WikiPage: + """Create a root wiki page fixture shared across tests in this class.""" + project = Project(name=f"Test Wiki Project_" + str(uuid.uuid4())) + project = await project.store_async(synapse_client=syn) + schedule_for_cleanup(project.id) + wiki_title = f"Root Wiki Page {str(uuid.uuid4())}" + wiki_markdown = "# Root Wiki Page\n\nThis is a root wiki page." + wiki_page = WikiPage( + owner_id=project.id, + title=wiki_title, + markdown=wiki_markdown, + ) + root_wiki = await wiki_page.store_async(synapse_client=syn) + return root_wiki + @pytest.fixture(autouse=True, scope="function") def init(self, syn: Synapse, schedule_for_cleanup: Callable[..., None]) -> None: self.syn = syn @@ -239,11 +253,17 @@ async def test_get_attachment_preview_url( ) -> None: # GIVEN a wiki page with an attachment wiki_page, attachment_name = wiki_page_with_attachment - # Sleep for 0.5 minutes to ensure the attachment preview is created - await asyncio.sleep(0.5 * 60) - # WHEN getting attachment preview URL - preview_url = await wiki_page.get_attachment_preview_async( - file_name=attachment_name, download_file=False, synapse_client=self.syn + + # WHEN polling until attachment preview is available + preview_url = await wait_for_condition( + condition_fn=lambda: wiki_page.get_attachment_preview_async( + file_name=attachment_name, + download_file=False, + synapse_client=self.syn, + ), + timeout_seconds=60, + poll_interval_seconds=5, + description="attachment preview to be generated", ) # THEN a URL should be returned @@ -262,7 +282,17 @@ async def test_download_attachment_preview( download_dir = tempfile.mkdtemp() self.schedule_for_cleanup(download_dir) - await asyncio.sleep(15) + # Poll until attachment preview is available for download + await wait_for_condition( + condition_fn=lambda: wiki_page.get_attachment_preview_async( + file_name=attachment_name, + download_file=False, + synapse_client=self.syn, + ), + timeout_seconds=60, + poll_interval_seconds=5, + description="attachment preview to be available for download", + ) # WHEN downloading the attachment preview downloaded_path = await wiki_page.get_attachment_preview_async( @@ -272,7 +302,7 @@ async def test_download_attachment_preview( synapse_client=self.syn, ) schedule_for_cleanup(downloaded_path) - # THEN the file should be downloadeds + # THEN the file should be downloaded assert os.path.exists(downloaded_path) assert os.path.basename(downloaded_path) == "preview.txt" @@ -425,11 +455,17 @@ async def test_get_attachment_preview_url_gz_file( """Test getting attachment preview URL for a gz file.""" # GIVEN a wiki page with a gz attachment wiki_page, attachment_name = wiki_page_with_gz_attachment - # Sleep for 0.5 minutes to ensure the attachment preview is created - time.sleep(0.5 * 60) - # WHEN getting attachment preview URL - preview_url = await wiki_page.get_attachment_preview_async( - file_name=attachment_name, download_file=False, synapse_client=self.syn + + # WHEN polling until attachment preview is available + preview_url = await wait_for_condition( + condition_fn=lambda: wiki_page.get_attachment_preview_async( + file_name=attachment_name, + download_file=False, + synapse_client=self.syn, + ), + timeout_seconds=60, + poll_interval_seconds=5, + description="attachment preview to be generated", ) # THEN a URL should be returned @@ -445,7 +481,17 @@ async def test_download_attachment_preview_gz_file( # GIVEN a wiki page with a gz attachment wiki_page, attachment_name = wiki_page_with_gz_attachment - await asyncio.sleep(15) + # Poll until attachment preview is available for download + await wait_for_condition( + condition_fn=lambda: wiki_page.get_attachment_preview_async( + file_name=attachment_name, + download_file=False, + synapse_client=self.syn, + ), + timeout_seconds=60, + poll_interval_seconds=5, + description="attachment preview to be available for download", + ) # AND a download location download_dir = tempfile.mkdtemp() @@ -468,6 +514,24 @@ async def test_download_attachment_preview_gz_file( class TestWikiPageMarkdown: """Tests for WikiPage markdown operations.""" + @pytest.fixture(scope="class") + async def wiki_page_fixture( + self, syn: Synapse, schedule_for_cleanup: Callable[..., None] + ) -> WikiPage: + """Create a root wiki page fixture shared across tests in this class.""" + project = Project(name=f"Test Wiki Project_" + str(uuid.uuid4())) + project = await project.store_async(synapse_client=syn) + schedule_for_cleanup(project.id) + wiki_title = f"Root Wiki Page {str(uuid.uuid4())}" + wiki_markdown = "# Root Wiki Page\n\nThis is a root wiki page." + wiki_page = WikiPage( + owner_id=project.id, + title=wiki_title, + markdown=wiki_markdown, + ) + root_wiki = await wiki_page.store_async(synapse_client=syn) + return root_wiki + @pytest.fixture(autouse=True, scope="function") def init(self, syn: Synapse, schedule_for_cleanup: Callable[..., None]) -> None: self.syn = syn @@ -607,6 +671,24 @@ async def test_download_markdown_file_gz_file( class TestWikiPageVersioning: """Tests for WikiPage version operations.""" + @pytest.fixture(scope="class") + async def wiki_page_fixture( + self, syn: Synapse, schedule_for_cleanup: Callable[..., None] + ) -> WikiPage: + """Create a root wiki page fixture shared across tests in this class.""" + project = Project(name=f"Test Wiki Project_" + str(uuid.uuid4())) + project = await project.store_async(synapse_client=syn) + schedule_for_cleanup(project.id) + wiki_title = f"Root Wiki Page {str(uuid.uuid4())}" + wiki_markdown = "# Root Wiki Page\n\nThis is a root wiki page." + wiki_page = WikiPage( + owner_id=project.id, + title=wiki_title, + markdown=wiki_markdown, + ) + root_wiki = await wiki_page.store_async(synapse_client=syn) + return root_wiki + @pytest.fixture(autouse=True, scope="function") def init(self, syn: Synapse, schedule_for_cleanup: Callable[..., None]) -> None: self.syn = syn @@ -680,6 +762,24 @@ async def test_restore_wiki_page_version( class TestWikiHeader: """Tests for WikiHeader operations.""" + @pytest.fixture(scope="class") + async def wiki_page_fixture( + self, syn: Synapse, schedule_for_cleanup: Callable[..., None] + ) -> WikiPage: + """Create a root wiki page fixture shared across tests in this class.""" + project = Project(name=f"Test Wiki Project_" + str(uuid.uuid4())) + project = await project.store_async(synapse_client=syn) + schedule_for_cleanup(project.id) + wiki_title = f"Root Wiki Page {str(uuid.uuid4())}" + wiki_markdown = "# Root Wiki Page\n\nThis is a root wiki page." + wiki_page = WikiPage( + owner_id=project.id, + title=wiki_title, + markdown=wiki_markdown, + ) + root_wiki = await wiki_page.store_async(synapse_client=syn) + return root_wiki + @pytest.fixture(autouse=True, scope="function") def init(self, syn: Synapse, schedule_for_cleanup: Callable[..., None]) -> None: self.syn = syn @@ -688,7 +788,7 @@ def init(self, syn: Synapse, schedule_for_cleanup: Callable[..., None]) -> None: async def test_get_wiki_header_tree( self, wiki_page_fixture: WikiPage, schedule_for_cleanup: Callable[..., None] ) -> None: - await asyncio.sleep(15) + await asyncio.sleep(5) # WHEN getting the wiki header tree headers = [] async for header in WikiHeader.get_async( @@ -704,6 +804,24 @@ async def test_get_wiki_header_tree( class TestWikiOrderHint: """Tests for WikiOrderHint operations.""" + @pytest.fixture(scope="class") + async def wiki_page_fixture( + self, syn: Synapse, schedule_for_cleanup: Callable[..., None] + ) -> WikiPage: + """Create a root wiki page fixture shared across tests in this class.""" + project = Project(name=f"Test Wiki Project_" + str(uuid.uuid4())) + project = await project.store_async(synapse_client=syn) + schedule_for_cleanup(project.id) + wiki_title = f"Root Wiki Page {str(uuid.uuid4())}" + wiki_markdown = "# Root Wiki Page\n\nThis is a root wiki page." + wiki_page = WikiPage( + owner_id=project.id, + title=wiki_title, + markdown=wiki_markdown, + ) + root_wiki = await wiki_page.store_async(synapse_client=syn) + return root_wiki + @pytest.fixture(autouse=True, scope="function") def init(self, syn: Synapse, schedule_for_cleanup: Callable[..., None]) -> None: self.syn = syn @@ -714,7 +832,7 @@ async def test_get_wiki_order_hint( wiki_page_fixture: WikiPage, schedule_for_cleanup: Callable[..., None], ) -> None: - await asyncio.sleep(15) + await asyncio.sleep(5) # WHEN getting the wiki order hint order_hint = await WikiOrderHint(owner_id=wiki_page_fixture.owner_id).get_async( synapse_client=self.syn @@ -728,7 +846,7 @@ async def test_get_wiki_order_hint( async def test_store_wiki_order_hint( self, wiki_page_fixture: WikiPage, schedule_for_cleanup: Callable[..., None] ) -> None: - await asyncio.sleep(15) + await asyncio.sleep(5) # Get headers headers = [] async for header in WikiHeader.get_async( @@ -746,7 +864,7 @@ async def test_store_wiki_order_hint( order_hint.id_list = header_ids updated_order_hint = await order_hint.store_async(synapse_client=self.syn) schedule_for_cleanup(updated_order_hint) - await asyncio.sleep(15) + await asyncio.sleep(5) # THEN the order hint should be updated # Retrieve the updated order hint retrieved_order_hint = await WikiOrderHint( diff --git a/tests/integration/synapseclient/models/synchronous/test_activity.py b/tests/integration/synapseclient/models/synchronous/test_activity.py deleted file mode 100644 index 4216f1377..000000000 --- a/tests/integration/synapseclient/models/synchronous/test_activity.py +++ /dev/null @@ -1,524 +0,0 @@ -"""Integration tests for Activity.""" - -import time -import uuid -from typing import Callable - -import pytest - -import synapseclient.core.utils as utils -from synapseclient import Project as Synapse_Project -from synapseclient import Synapse -from synapseclient.models import Activity, File, UsedEntity, UsedURL - -BOGUS_URL = "https://www.synapse.org/" - - -class TestActivity: - """Integration tests for Activity.""" - - @pytest.fixture(autouse=True, scope="function") - def init(self, syn: Synapse, schedule_for_cleanup: Callable[..., None]) -> None: - self.syn = syn - self.schedule_for_cleanup = schedule_for_cleanup - - def create_file_with_activity( - self, - project: Synapse_Project, - activity: Activity = None, - store_file: bool = True, - ) -> File: - """Helper to create a file with optional activity""" - path = utils.make_bogus_uuid_file() - file = File( - parent_id=project["id"], - path=path, - name=f"bogus_file_{str(uuid.uuid4())}", - activity=activity, - ) - self.schedule_for_cleanup(file.path) - - if store_file: - file.store(synapse_client=self.syn) - self.schedule_for_cleanup(file.id) - - return file - - def verify_activity_properties( - self, activity, expected_name, expected_description, has_references=False - ): - """Helper to verify common activity properties""" - assert activity.name == expected_name - assert activity.description == expected_description - assert activity.id is not None - assert activity.etag is not None - assert activity.created_on is not None - assert activity.modified_on is not None - assert activity.created_by is not None - assert activity.modified_by is not None - - if has_references: - # Verify used references - assert len(activity.used) > 0 - assert activity.used[0].url == BOGUS_URL - assert activity.used[0].name == "example" - if len(activity.used) > 1: - assert activity.used[1].target_id == "syn456" - assert activity.used[1].target_version_number == 1 - - # Verify executed references if they exist - if len(activity.executed) > 0: - assert activity.executed[0].url == BOGUS_URL - assert activity.executed[0].name == "example" - if len(activity.executed) > 1: - assert activity.executed[1].target_id == "syn789" - assert activity.executed[1].target_version_number == 1 - else: - assert activity.used == [] - assert activity.executed == [] - - def test_activity_lifecycle(self, project: Synapse_Project) -> None: - """Test complete activity lifecycle - create, update, retrieve, and delete""" - # GIVEN a file in a project - file = self.create_file_with_activity(project) - - # AND an activity with references - activity = Activity( - name=f"some_name_{str(uuid.uuid4())}", - description="some_description", - used=[ - UsedURL(name="example", url=BOGUS_URL), - UsedEntity(target_id="syn456", target_version_number=1), - ], - executed=[ - UsedURL(name="example", url=BOGUS_URL), - UsedEntity(target_id="syn789", target_version_number=1), - ], - ) - - # WHEN I store the activity - result = activity.store(parent=file, synapse_client=self.syn) - self.schedule_for_cleanup(result.id) - - # THEN I expect the activity to be stored correctly - assert result == activity - self.verify_activity_properties( - result, activity.name, "some_description", has_references=True - ) - - # WHEN I modify and store the activity - modified_name = f"modified_name_{str(uuid.uuid4())}" - result.name = modified_name - result.description = "modified_description" - modified_result = result.store(synapse_client=self.syn) - - # THEN I expect the modified activity to be stored - self.verify_activity_properties( - modified_result, - modified_name, - "modified_description", - has_references=True, - ) - - # WHEN I get the activity from the file - retrieved_activity = Activity.from_parent(parent=file, synapse_client=self.syn) - - # THEN I expect the retrieved activity to match the modified one - assert retrieved_activity.name == modified_name - assert retrieved_activity.description == "modified_description" - self.verify_activity_properties( - retrieved_activity, - modified_name, - "modified_description", - has_references=True, - ) - - # WHEN I delete the activity - result.delete(parent=file, synapse_client=self.syn) - - # THEN I expect no activity to be associated with the file - activity_after_delete = Activity.from_parent( - parent=file, synapse_client=self.syn - ) - assert activity_after_delete is None - - def test_store_activity_with_no_references(self, project: Synapse_Project) -> None: - """Test storing an activity without references""" - # GIVEN an activity with no references - activity = Activity( - name=f"simple_activity_{str(uuid.uuid4())}", - description="activity with no references", - ) - - # AND a file with that activity - file = self.create_file_with_activity(project, activity=activity) - - # THEN I expect the activity to have been stored properly - self.verify_activity_properties( - file.activity, - activity.name, - "activity with no references", - has_references=False, - ) - - # Clean up - file.activity.delete(parent=file, synapse_client=self.syn) - - def test_store_activity_via_file_creation(self, project: Synapse_Project) -> None: - """Test storing an activity as part of file creation""" - # GIVEN an activity with references - activity = Activity( - name=f"file_activity_{str(uuid.uuid4())}", - description="activity stored with file", - used=[ - UsedURL(name="example", url=BOGUS_URL), - UsedEntity(target_id="syn456", target_version_number=1), - ], - executed=[ - UsedURL(name="example", url=BOGUS_URL), - UsedEntity(target_id="syn789", target_version_number=1), - ], - ) - - # WHEN I create a file with the activity - file = self.create_file_with_activity(project, activity=activity) - - # THEN I expect the activity to have been stored with the file - self.verify_activity_properties( - file.activity, - activity.name, - "activity stored with file", - has_references=True, - ) - - # Clean up - file.activity.delete(parent=file, synapse_client=self.syn) - - def test_get_by_activity_id(self, project: Synapse_Project) -> None: - """Test retrieving an activity by its ID""" - # GIVEN a file with an activity - activity = Activity( - name=f"test_get_by_id_{str(uuid.uuid4())}", - description="activity for get by id test", - used=[ - UsedURL(name="example", url=BOGUS_URL), - UsedEntity(target_id="syn456", target_version_number=1), - ], - ) - file = self.create_file_with_activity(project, activity=activity) - stored_activity = file.activity - - # WHEN I retrieve the activity by its ID - retrieved_activity = Activity.get( - activity_id=stored_activity.id, synapse_client=self.syn - ) - - # THEN I expect to get the same activity - assert retrieved_activity is not None - assert retrieved_activity.id == stored_activity.id - assert retrieved_activity.name == activity.name - assert retrieved_activity.description == "activity for get by id test" - self.verify_activity_properties( - retrieved_activity, - activity.name, - "activity for get by id test", - has_references=True, - ) - - # Clean up - stored_activity.delete(parent=file, synapse_client=self.syn) - - def test_get_by_parent_id(self, project: Synapse_Project) -> None: - """Test retrieving an activity by parent entity ID""" - # GIVEN a file with an activity - activity = Activity( - name=f"test_get_by_parent_{str(uuid.uuid4())}", - description="activity for get by parent test", - used=[UsedURL(name="example", url=BOGUS_URL)], - ) - file = self.create_file_with_activity(project, activity=activity) - stored_activity = file.activity - time.sleep(2) - - # WHEN I retrieve the activity by parent ID - retrieved_activity = Activity.get(parent_id=file.id, synapse_client=self.syn) - - # THEN I expect to get the same activity - assert retrieved_activity is not None - assert retrieved_activity.id == stored_activity.id - assert retrieved_activity.name == activity.name - assert retrieved_activity.description == "activity for get by parent test" - self.verify_activity_properties( - retrieved_activity, - activity.name, - "activity for get by parent test", - has_references=True, - ) - - # Clean up - stored_activity.delete(parent=file, synapse_client=self.syn) - - def test_get_by_parent_id_with_version(self, project: Synapse_Project) -> None: - """Test retrieving an activity by parent entity ID with version number""" - # GIVEN a file with an activity - activity = Activity( - name=f"test_get_by_parent_version_{str(uuid.uuid4())}", - description="activity for get by parent version test", - ) - file = self.create_file_with_activity(project, activity=activity) - stored_activity = file.activity - time.sleep(2) - - # WHEN I retrieve the activity by parent ID with version - retrieved_activity = Activity.get( - parent_id=file.id, - parent_version_number=file.version_number, - synapse_client=self.syn, - ) - - # THEN I expect to get the same activity - assert retrieved_activity is not None - assert retrieved_activity.id == stored_activity.id - assert retrieved_activity.name == activity.name - assert ( - retrieved_activity.description == "activity for get by parent version test" - ) - - # Clean up - stored_activity.delete(parent=file, synapse_client=self.syn) - - def test_get_nonexistent_activity(self) -> None: - """Test retrieving a nonexistent activity returns None""" - # WHEN I try to retrieve a nonexistent activity by ID - retrieved_activity = Activity.get( - activity_id="syn999999999", synapse_client=self.syn - ) - - # THEN I expect to get None - assert retrieved_activity is None - - # AND when I try to retrieve by nonexistent parent ID - retrieved_activity = Activity.get( - parent_id="syn999999999", synapse_client=self.syn - ) - - # THEN I expect to get None - assert retrieved_activity is None - - def test_get_activity_id_takes_precedence(self, project: Synapse_Project) -> None: - """Test that activity_id takes precedence over parent_id when both are provided""" - # GIVEN two files with different activities - activity1 = Activity( - name=f"activity_1_{str(uuid.uuid4())}", - description="first activity", - ) - activity2 = Activity( - name=f"activity_2_{str(uuid.uuid4())}", - description="second activity", - ) - - file1 = self.create_file_with_activity(project, activity=activity1) - file2 = self.create_file_with_activity(project, activity=activity2) - - stored_activity1 = file1.activity - stored_activity2 = file2.activity - time.sleep(2) - - # WHEN I retrieve using activity_id from first activity and parent_id from second - retrieved_activity = Activity.get( - activity_id=stored_activity1.id, parent_id=file2.id, synapse_client=self.syn - ) - - # THEN I expect to get the first activity (activity_id takes precedence) - assert retrieved_activity is not None - assert retrieved_activity.id == stored_activity1.id - assert retrieved_activity.name == activity1.name - assert retrieved_activity.description == "first activity" - - # Clean up - stored_activity1.delete(parent=file1, synapse_client=self.syn) - stored_activity2.delete(parent=file2, synapse_client=self.syn) - - def test_get_no_parameters_raises_error(self) -> None: - """Test that calling get() without parameters raises ValueError""" - # WHEN I try to call get() without any parameters - # THEN I expect a ValueError to be raised - with pytest.raises( - ValueError, match="Either activity_id or parent_id must be provided" - ): - Activity.get() - - def test_store_activity_with_string_parent(self, project: Synapse_Project) -> None: - """Test storing an activity with a string parent ID""" - # GIVEN a file in a project - file = self.create_file_with_activity(project) - - # AND an activity with references - activity = Activity( - name=f"string_parent_test_{str(uuid.uuid4())}", - description="testing string parent ID", - used=[ - UsedURL(name="example", url=BOGUS_URL), - UsedEntity(target_id="syn456", target_version_number=1), - ], - ) - - # WHEN I store the activity using a string parent ID - result = activity.store(parent=file.id, synapse_client=self.syn) - self.schedule_for_cleanup(result.id) - - # THEN I expect the activity to be stored correctly - assert result == activity - self.verify_activity_properties( - result, activity.name, "testing string parent ID", has_references=True - ) - - # AND when I retrieve it from the file - retrieved_activity = Activity.from_parent(parent=file, synapse_client=self.syn) - assert retrieved_activity.id == result.id - assert retrieved_activity.name == activity.name - - # Clean up - Activity.delete(parent=file.id, synapse_client=self.syn) - - def test_from_parent_with_string_parent(self, project: Synapse_Project) -> None: - """Test retrieving an activity using a string parent ID""" - # GIVEN a file with an activity - activity = Activity( - name=f"from_parent_string_test_{str(uuid.uuid4())}", - description="testing from_parent with string", - used=[UsedURL(name="example", url=BOGUS_URL)], - ) - file = self.create_file_with_activity(project, activity=activity) - stored_activity = file.activity - - # WHEN I retrieve the activity using a string parent ID - retrieved_activity = Activity.from_parent( - parent=file.id, synapse_client=self.syn - ) - - # THEN I expect to get the same activity - assert retrieved_activity is not None - assert retrieved_activity.id == stored_activity.id - assert retrieved_activity.name == activity.name - assert retrieved_activity.description == "testing from_parent with string" - - # Clean up - Activity.delete(parent=file, synapse_client=self.syn) - - def test_from_parent_with_string_parent_and_version( - self, project: Synapse_Project - ) -> None: - """Test retrieving an activity using a string parent ID with version""" - # GIVEN a file with an activity - activity = Activity( - name=f"from_parent_string_version_test_{str(uuid.uuid4())}", - description="testing from_parent with string and version", - ) - file = self.create_file_with_activity(project, activity=activity) - stored_activity = file.activity - - # WHEN I retrieve the activity using a string parent ID with version parameter - retrieved_activity = Activity.from_parent( - parent=file.id, - parent_version_number=file.version_number, - synapse_client=self.syn, - ) - - # THEN I expect to get the same activity - assert retrieved_activity is not None - assert retrieved_activity.id == stored_activity.id - assert retrieved_activity.name == activity.name - - # Clean up - Activity.delete(parent=file, synapse_client=self.syn) - - def test_from_parent_with_string_parent_with_embedded_version( - self, project: Synapse_Project - ) -> None: - """Test retrieving an activity using a string parent ID with embedded version""" - # GIVEN a file with an activity - activity = Activity( - name=f"from_parent_embedded_version_test_{str(uuid.uuid4())}", - description="testing from_parent with embedded version", - ) - file = self.create_file_with_activity(project, activity=activity) - stored_activity = file.activity - - # WHEN I retrieve the activity using a string parent ID with embedded version - parent_with_version = f"{file.id}.{file.version_number}" - retrieved_activity = Activity.from_parent( - parent=parent_with_version, synapse_client=self.syn - ) - - # THEN I expect to get the same activity - assert retrieved_activity is not None - assert retrieved_activity.id == stored_activity.id - assert retrieved_activity.name == activity.name - - # Clean up - Activity.delete(parent=file, synapse_client=self.syn) - - def test_from_parent_version_precedence(self, project: Synapse_Project) -> None: - """Test that embedded version takes precedence over parent_version_number parameter""" - # GIVEN a file with an activity - activity = Activity( - name=f"version_precedence_test_{str(uuid.uuid4())}", - description="testing version precedence", - ) - file = self.create_file_with_activity(project, activity=activity) - stored_activity = file.activity - - # WHEN I retrieve the activity using a string parent ID with embedded version - # and also provide a different parent_version_number parameter - parent_with_version = f"{file.id}.{file.version_number}" - wrong_version = file.version_number + 1 if file.version_number > 1 else 999 - retrieved_activity = Activity.from_parent( - parent=parent_with_version, - parent_version_number=wrong_version, - synapse_client=self.syn, - ) - - # THEN I expect to get the activity (embedded version should take precedence) - assert retrieved_activity is not None - assert retrieved_activity.id == stored_activity.id - assert retrieved_activity.name == activity.name - - # Clean up - Activity.delete(parent=file, synapse_client=self.syn) - - def test_delete_with_string_parent(self, project: Synapse_Project) -> None: - """Test deleting an activity using a string parent ID""" - # GIVEN a file with an activity - activity = Activity( - name=f"delete_string_test_{str(uuid.uuid4())}", - description="testing delete with string parent", - ) - file = self.create_file_with_activity(project, activity=activity) - - # WHEN I delete the activity using a string parent ID - Activity.delete(parent=file.id, synapse_client=self.syn) - - # THEN I expect no activity to be associated with the file - activity_after_delete = Activity.from_parent( - parent=file, synapse_client=self.syn - ) - assert activity_after_delete is None - - def test_disassociate_with_string_parent(self, project: Synapse_Project) -> None: - """Test disassociating an activity using a string parent ID""" - # GIVEN a file with an activity - activity = Activity( - name=f"disassociate_string_test_{str(uuid.uuid4())}", - description="testing disassociate with string parent", - ) - file = self.create_file_with_activity(project, activity=activity) - - # WHEN I disassociate the activity using a string parent ID - Activity.disassociate_from_entity(parent=file.id, synapse_client=self.syn) - - # THEN I expect no activity to be associated with the file - activity_after_disassociate = Activity.from_parent( - parent=file, synapse_client=self.syn - ) - assert activity_after_disassociate is None diff --git a/tests/integration/synapseclient/models/synchronous/test_agent.py b/tests/integration/synapseclient/models/synchronous/test_agent.py deleted file mode 100644 index 0aa13584b..000000000 --- a/tests/integration/synapseclient/models/synchronous/test_agent.py +++ /dev/null @@ -1,206 +0,0 @@ -"""Integration tests for the synchronous methods of the AgentSession and Agent classes.""" - -# These tests have been disabled until out `test` user has needed permissions -# Context: https://sagebionetworks.jira.com/browse/SYNPY-1544?focusedCommentId=235070 -import pytest - -from synapseclient import Synapse -from synapseclient.models.agent import Agent, AgentSession, AgentSessionAccessLevel - -# These are the ID values for a "Hello World" agent registered on Synapse. -# The Bedrock agent is hosted on Sage Bionetworks AWS infrastructure. -# CFN Template: -# https://raw.githubusercontent.com/Sage-Bionetworks-Workflows/dpe-agents/refs/heads/main/client_integration_test/template.json -AGENT_AWS_ID = "QOTV3KQM1X" - - -class TestAgentSession: - """Integration tests for the synchronous methods of the AgentSession class.""" - - @pytest.fixture(autouse=True, scope="function") - def init(self, syn: Synapse) -> None: - self.syn = syn - if syn.repoEndpoint == "https://repo-dev.dev.sagebase.org/repo/v1": - self.AGENT_REGISTRATION_ID = "7" - else: - self.AGENT_REGISTRATION_ID = "29" - - def test_start(self) -> None: - # GIVEN an agent session with a valid agent registration id - agent_session = AgentSession(agent_registration_id=self.AGENT_REGISTRATION_ID) - - # WHEN the start method is called - result_session = agent_session.start(synapse_client=self.syn) - - # THEN the result should be an AgentSession object - # with expected attributes including an empty chat history - assert result_session.id is not None - assert ( - result_session.access_level == AgentSessionAccessLevel.PUBLICLY_ACCESSIBLE - ) - assert result_session.started_on is not None - assert result_session.started_by is not None - assert result_session.modified_on is not None - assert result_session.agent_registration_id == str(self.AGENT_REGISTRATION_ID) - assert result_session.etag is not None - assert result_session.chat_history == [] - - def test_get(self) -> None: - # GIVEN an agent session with a valid agent registration id - agent_session = AgentSession(agent_registration_id=self.AGENT_REGISTRATION_ID) - # WHEN I start a session - agent_session.start(synapse_client=self.syn) - # THEN I expect to be able to get the session with its id - new_session = AgentSession(id=agent_session.id).get(synapse_client=self.syn) - assert new_session == agent_session - - def test_update(self) -> None: - # GIVEN an agent session with a valid agent registration id and access level set - agent_session = AgentSession( - agent_registration_id=self.AGENT_REGISTRATION_ID, - access_level=AgentSessionAccessLevel.PUBLICLY_ACCESSIBLE, - ) - # WHEN I start a session - agent_session.start(synapse_client=self.syn) - # AND I update the access level of the session - agent_session.access_level = AgentSessionAccessLevel.READ_YOUR_PRIVATE_DATA - agent_session.update(synapse_client=self.syn) - # THEN I expect the access level to be updated - updated_session = AgentSession(id=agent_session.id).get(synapse_client=self.syn) - assert ( - updated_session.access_level - == AgentSessionAccessLevel.READ_YOUR_PRIVATE_DATA - ) - - def test_prompt(self) -> None: - # GIVEN an agent session with a valid agent registration id - agent_session = AgentSession(agent_registration_id=self.AGENT_REGISTRATION_ID) - # WHEN I start a session - agent_session.start(synapse_client=self.syn) - # THEN I expect to be able to prompt the agent - agent_session.prompt( - prompt="hello", - enable_trace=True, - synapse_client=self.syn, - ) - # AND I expect the chat history to be updated with the prompt and response - assert len(agent_session.chat_history) == 1 - assert agent_session.chat_history[0].prompt == "hello" - assert agent_session.chat_history[0].response is not None - assert agent_session.chat_history[0].trace is not None - - -class TestAgent: - """Integration tests for the synchronous methods of the Agent class.""" - - @pytest.fixture(autouse=True, scope="function") - def init(self, syn: Synapse) -> None: - self.syn = syn - - if syn.repoEndpoint == "https://repo-dev.dev.sagebase.org/repo/v1": - self.AGENT_REGISTRATION_ID = "7" - registered_on = "2025-08-11T20:39:35.355Z" - else: - self.AGENT_REGISTRATION_ID = "29" - registered_on = "2025-01-16T18:57:35.680Z" - - self.agent = Agent( - cloud_agent_id=AGENT_AWS_ID, - cloud_alias_id="TSTALIASID", - registration_id=self.AGENT_REGISTRATION_ID, - registered_on=registered_on, - type="CUSTOM", - sessions={}, - current_session=None, - ) - - def test_register(self) -> None: - # GIVEN an Agent with a valid agent AWS id - agent = Agent(cloud_agent_id=AGENT_AWS_ID) - # WHEN I register the agent - agent.register(synapse_client=self.syn) - # THEN I expect the agent to be registered - expected_agent = self.agent - assert agent == expected_agent - - def test_get(self) -> None: - # GIVEN an Agent with a valid agent registration id - agent = Agent(registration_id=self.AGENT_REGISTRATION_ID) - # WHEN I get the agent - agent.get(synapse_client=self.syn) - # THEN I expect the agent to be returned - expected_agent = self.agent - assert agent == expected_agent - - def test_get_no_registration_id(self) -> None: - # GIVEN an Agent with no registration id - agent = Agent() - # WHEN I get the agent, I expect a ValueError to be raised - with pytest.raises(ValueError, match="Registration ID is required"): - agent.get(synapse_client=self.syn) - - def test_start_session(self) -> None: - # GIVEN an Agent with a valid agent registration id - agent = Agent(registration_id=self.AGENT_REGISTRATION_ID).get( - synapse_client=self.syn - ) - # WHEN I start a session - agent.start_session(synapse_client=self.syn) - # THEN I expect a current session to be set - assert agent.current_session is not None - # AND I expect the session to be in the sessions dictionary - assert agent.sessions[agent.current_session.id] == agent.current_session - - def test_get_session(self) -> None: - # GIVEN an Agent with a valid agent registration id - agent = Agent(registration_id=self.AGENT_REGISTRATION_ID).get( - synapse_client=self.syn - ) - # WHEN I start a session - session = agent.start_session(synapse_client=self.syn) - # THEN I expect to be able to get the session with its id - existing_session = agent.get_session( - session_id=session.id, synapse_client=self.syn - ) - # AND I expect those sessions to be the same - assert existing_session == session - # AND I expect it to be the current session - assert existing_session == agent.current_session - - def test_prompt_with_session(self) -> None: - # GIVEN an Agent with a valid agent registration id - agent = Agent(registration_id=self.AGENT_REGISTRATION_ID).get( - synapse_client=self.syn - ) - # AND a session started separately - session = AgentSession(agent_registration_id=self.AGENT_REGISTRATION_ID).start( - synapse_client=self.syn - ) - # WHEN I prompt the agent with a session - agent.prompt( - prompt="hello", enable_trace=True, session=session, synapse_client=self.syn - ) - test_session = agent.sessions[session.id] - # THEN I expect the chat history to be updated with the prompt and response - assert len(test_session.chat_history) == 1 - assert test_session.chat_history[0].prompt == "hello" - assert test_session.chat_history[0].response is not None - assert test_session.chat_history[0].trace is not None - # AND I expect the current session to be the session provided - assert agent.current_session.id == session.id - - def test_prompt_no_session(self) -> None: - # GIVEN an Agent with a valid agent registration id - agent = Agent(registration_id=self.AGENT_REGISTRATION_ID).get( - synapse_client=self.syn - ) - # WHEN I prompt the agent without a current session set - # and no session provided - agent.prompt(prompt="hello", enable_trace=True, synapse_client=self.syn) - # THEN I expect a new session to be started and set as the current session - assert agent.current_session is not None - # AND I expect the chat history to be updated with the prompt and response - assert len(agent.current_session.chat_history) == 1 - assert agent.current_session.chat_history[0].prompt == "hello" - assert agent.current_session.chat_history[0].response is not None - assert agent.current_session.chat_history[0].trace is not None diff --git a/tests/integration/synapseclient/models/synchronous/test_column.py b/tests/integration/synapseclient/models/synchronous/test_column.py deleted file mode 100644 index 1b1686baf..000000000 --- a/tests/integration/synapseclient/models/synchronous/test_column.py +++ /dev/null @@ -1,194 +0,0 @@ -import uuid -from typing import Callable - -import pytest - -from synapseclient import Synapse -from synapseclient.core.exceptions import SynapseHTTPError -from synapseclient.models import Column, ColumnType, Project, Table - - -class TestColumn: - @pytest.fixture(autouse=True, scope="function") - def init(self, syn: Synapse, schedule_for_cleanup: Callable[..., None]) -> None: - self.syn = syn - self.schedule_for_cleanup = schedule_for_cleanup - - def test_get_column_by_id(self, project_model: Project) -> None: - """Test getting a column by its ID.""" - # GIVEN a table with a column - table_name = str(uuid.uuid4()) - table = Table( - name=table_name, - parent_id=project_model.id, - columns=[ - Column( - name="test_column", column_type=ColumnType.STRING, maximum_size=50 - ) - ], - ) - - # WHEN I store the table - table = table.store(synapse_client=self.syn) - self.schedule_for_cleanup(table.id) - - # AND I retrieve the table with columns - retrieved_table = Table(id=table.id).get( - include_columns=True, synapse_client=self.syn - ) - - # AND I get the column ID - column_id = retrieved_table.columns["test_column"].id - - # WHEN I get the column by ID - column = Column(id=column_id).get(synapse_client=self.syn) - - # THEN the column should be retrieved successfully - assert column.id == column_id - assert column.name == "test_column" - assert column.column_type == ColumnType.STRING - assert column.maximum_size == 50 - - def test_get_column_by_invalid_id(self) -> None: - """Test getting a column by an invalid ID.""" - # GIVEN an invalid column ID - invalid_id = "999999999" - - # WHEN I try to get the column by invalid ID - # THEN it should raise an exception - with pytest.raises(SynapseHTTPError) as exc_info: - Column(id=invalid_id).get(synapse_client=self.syn) - - # Verify the error is a 404 Not Found - assert "404" in str(exc_info.value) - - def test_list_all_columns(self, project_model: Project) -> None: - """Test listing all columns without a prefix.""" - # GIVEN a single table with multiple columns - table_name = str(uuid.uuid4()) - table = Table( - name=table_name, - parent_id=project_model.id, - columns=[ - Column(name="test_list_all_col1", column_type=ColumnType.STRING), - Column(name="test_list_all_col2", column_type=ColumnType.INTEGER), - Column(name="test_list_all_col3", column_type=ColumnType.DOUBLE), - Column(name="test_list_all_col4", column_type=ColumnType.BOOLEAN), - ], - ) - table = table.store(synapse_client=self.syn) - self.schedule_for_cleanup(table.id) - - # WHEN I list all columns with a limit of 5 to test pagination - # Note: limit is per request, the function continues until all matching columns are returned - columns = [] - for column in Column.list(limit=5, synapse_client=self.syn): - columns.append(column) - # Limit to first 20 to avoid too many results - if len(columns) >= 20: - break - - # THEN I should get columns back - assert len(columns) > 0 - assert all(isinstance(col, Column) for col in columns) - assert all(col.id is not None for col in columns) - assert all(col.name is not None for col in columns) - - def test_list_columns_with_prefix(self, project_model: Project) -> None: - """Test listing columns with a prefix filter.""" - # GIVEN a table with columns that have a specific prefix - prefix = "test_prefix_column_filter" - table_name = str(uuid.uuid4()) - table = Table( - name=table_name, - parent_id=project_model.id, - columns=[ - Column( - name="test_prefix_column_filter_col1", column_type=ColumnType.STRING - ), - Column( - name="test_prefix_column_filter_col2", - column_type=ColumnType.INTEGER, - ), - Column(name="other_col_static", column_type=ColumnType.DOUBLE), - ], - ) - table = table.store(synapse_client=self.syn) - self.schedule_for_cleanup(table.id) - - # WHEN I list columns with the prefix using limit=5 to test pagination - # Note: limit is per request, the function continues until all matching columns are returned - columns = [] - for column in Column.list(prefix=prefix, limit=5, synapse_client=self.syn): - columns.append(column) - - # THEN I should get only the columns with the prefix - assert ( - len(columns) >= 2 - ) # May be more if other tests created columns with same prefix - assert all(isinstance(col, Column) for col in columns) - assert all(col.name.startswith(prefix) for col in columns) - - # Verify the specific columns we created are included - column_names = [col.name for col in columns] - assert "test_prefix_column_filter_col1" in column_names - assert "test_prefix_column_filter_col2" in column_names - assert "other_col" not in column_names - - def test_list_columns_with_limit_and_offset(self, project_model: Project) -> None: - """Test listing columns with limit and offset parameters to verify pagination behavior.""" - # GIVEN a single table with multiple columns - table_name = str(uuid.uuid4()) - table = Table( - name=table_name, - parent_id=project_model.id, - columns=[ - Column(name="test_limit_offset_col1", column_type=ColumnType.STRING), - Column(name="test_limit_offset_col2", column_type=ColumnType.INTEGER), - Column(name="test_limit_offset_col3", column_type=ColumnType.DOUBLE), - Column(name="test_limit_offset_col4", column_type=ColumnType.BOOLEAN), - Column(name="test_limit_offset_col5", column_type=ColumnType.DATE), - Column(name="test_limit_offset_col6", column_type=ColumnType.STRING), - ], - ) - table = table.store(synapse_client=self.syn) - self.schedule_for_cleanup(table.id) - - # WHEN I list columns with limit - columns_page1 = [] - for column in Column.list(limit=3, offset=0, synapse_client=self.syn): - columns_page1.append(column) - # Limit to first 3 to test pagination behavior - if len(columns_page1) >= 3: - break - - # THEN I should get columns back - assert len(columns_page1) <= 3 - assert all(isinstance(col, Column) for col in columns_page1) - - # WHEN I list columns with offset - columns_page2 = [] - for column in Column.list(limit=3, offset=3, synapse_client=self.syn): - columns_page2.append(column) - # Limit to first 3 to test pagination behavior - if len(columns_page2) >= 3: - break - - # THEN I should get columns back - assert len(columns_page2) <= 3 - assert all(isinstance(col, Column) for col in columns_page2) - - def test_list_columns_with_no_prefix_match(self) -> None: - """Test listing columns with a prefix that doesn't match any columns.""" - # GIVEN a unique prefix that won't match any existing columns - unique_prefix = "nonexistent_prefix_static" - - # WHEN I list columns with the non-matching prefix using limit=5 - columns = [] - for column in Column.list( - prefix=unique_prefix, limit=5, synapse_client=self.syn - ): - columns.append(column) - - # THEN I should get no columns - assert len(columns) == 0 diff --git a/tests/integration/synapseclient/models/synchronous/test_curation.py b/tests/integration/synapseclient/models/synchronous/test_curation.py deleted file mode 100644 index cd78bf7e0..000000000 --- a/tests/integration/synapseclient/models/synchronous/test_curation.py +++ /dev/null @@ -1,665 +0,0 @@ -"""Integration tests for the synapseclient.models.CurationTask class.""" - -import os -import tempfile -import uuid -from typing import Callable - -import pandas as pd -import pytest - -from synapseclient import Synapse -from synapseclient.core.exceptions import SynapseHTTPError -from synapseclient.models import ( - Column, - ColumnType, - CurationTask, - EntityView, - FileBasedMetadataTaskProperties, - Folder, - Project, - RecordBasedMetadataTaskProperties, - RecordSet, - Team, - ViewTypeMask, -) - - -class TestFileBasedMetadataTaskProperties: - """Tests for the FileBasedMetadataTaskProperties class.""" - - def test_fill_from_dict_and_to_synapse_request(self) -> None: - # GIVEN a dictionary representing a FileBasedMetadataTaskProperties response - response_dict = { - "concreteType": "org.sagebionetworks.repo.model.curation.metadata.FileBasedMetadataTaskProperties", - "uploadFolderId": "syn123456", - "fileViewId": "syn789012", - } - - # WHEN I create a FileBasedMetadataTaskProperties from the dict - properties = FileBasedMetadataTaskProperties().fill_from_dict(response_dict) - - # THEN the properties should be correctly populated - assert properties.upload_folder_id == "syn123456" - assert properties.file_view_id == "syn789012" - - # AND WHEN I convert it back to a request dict - request_dict = properties.to_synapse_request() - - # THEN the request dict should be correctly formatted - expected_concrete_type = ( - "org.sagebionetworks.repo.model.curation.metadata." - "FileBasedMetadataTaskProperties" - ) - assert request_dict["concreteType"] == expected_concrete_type - assert request_dict["uploadFolderId"] == "syn123456" - assert request_dict["fileViewId"] == "syn789012" - - def test_fill_from_dict_with_none_values(self) -> None: - # GIVEN a dictionary with missing optional fields - response_dict = { - "concreteType": "org.sagebionetworks.repo.model.curation.metadata.FileBasedMetadataTaskProperties" - } - - # WHEN I create a FileBasedMetadataTaskProperties from the dict - properties = FileBasedMetadataTaskProperties().fill_from_dict(response_dict) - - # THEN the properties should handle None values correctly - assert properties.upload_folder_id is None - assert properties.file_view_id is None - - def test_to_synapse_request_with_partial_data(self) -> None: - # GIVEN a FileBasedMetadataTaskProperties with only some fields set - properties = FileBasedMetadataTaskProperties(upload_folder_id="syn123456") - - # WHEN I convert it to a request dict - request_dict = properties.to_synapse_request() - - # THEN only the non-None fields should be included - expected_concrete_type = ( - "org.sagebionetworks.repo.model.curation.metadata." - "FileBasedMetadataTaskProperties" - ) - assert request_dict["concreteType"] == expected_concrete_type - assert request_dict["uploadFolderId"] == "syn123456" - assert "fileViewId" not in request_dict - - -class TestRecordBasedMetadataTaskProperties: - """Tests for the RecordBasedMetadataTaskProperties class.""" - - def test_fill_from_dict_and_to_synapse_request(self) -> None: - # GIVEN a dictionary representing a RecordBasedMetadataTaskProperties response - response_dict = { - "concreteType": "org.sagebionetworks.repo.model.curation.metadata.RecordBasedMetadataTaskProperties", - "recordSetId": "syn123456", - } - - # WHEN I create a RecordBasedMetadataTaskProperties from the dict - properties = RecordBasedMetadataTaskProperties().fill_from_dict(response_dict) - - # THEN the properties should be correctly populated - assert properties.record_set_id == "syn123456" - - # AND WHEN I convert it back to a request dict - request_dict = properties.to_synapse_request() - - # THEN the request dict should be correctly formatted - expected_concrete_type = ( - "org.sagebionetworks.repo.model.curation.metadata." - "RecordBasedMetadataTaskProperties" - ) - assert request_dict["concreteType"] == expected_concrete_type - assert request_dict["recordSetId"] == "syn123456" - - def test_fill_from_dict_with_none_values(self) -> None: - # GIVEN a dictionary with missing optional fields - response_dict = { - "concreteType": "org.sagebionetworks.repo.model.curation.metadata.RecordBasedMetadataTaskProperties" - } - - # WHEN I create a RecordBasedMetadataTaskProperties from the dict - properties = RecordBasedMetadataTaskProperties().fill_from_dict(response_dict) - - # THEN the properties should handle None values correctly - assert properties.record_set_id is None - - def test_to_synapse_request_with_none_values(self) -> None: - # GIVEN a RecordBasedMetadataTaskProperties with no record set ID - properties = RecordBasedMetadataTaskProperties() - - # WHEN I convert it to a request dict - request_dict = properties.to_synapse_request() - - # THEN only the concrete type should be included - expected_concrete_type = ( - "org.sagebionetworks.repo.model.curation.metadata." - "RecordBasedMetadataTaskProperties" - ) - assert request_dict["concreteType"] == expected_concrete_type - assert "recordSetId" not in request_dict - - -class TestCurationTaskStore: - """Tests for the CurationTask.store method.""" - - @pytest.fixture(autouse=True, scope="function") - def init(self, syn: Synapse, schedule_for_cleanup: Callable[..., None]) -> None: - self.syn = syn - self.schedule_for_cleanup = schedule_for_cleanup - - @pytest.fixture(scope="function") - def team(self) -> Team: - team = Team(name=f"test_team_{uuid.uuid4()}").create(synapse_client=self.syn) - self.schedule_for_cleanup(team) - return team - - @pytest.fixture(scope="function") - def folder_with_view(self, project_model: Project) -> tuple[Folder, EntityView]: - """Create a folder with an associated EntityView for file-based testing.""" - # Create a folder - folder = Folder( - name=str(uuid.uuid4()), - parent_id=project_model.id, - ).store(synapse_client=self.syn) - self.schedule_for_cleanup(folder.id) - - # Create an EntityView for the folder - columns = [ - Column(name="id", column_type=ColumnType.ENTITYID), - Column(name="name", column_type=ColumnType.STRING, maximum_size=256), - Column(name="createdOn", column_type=ColumnType.DATE), - Column(name="createdBy", column_type=ColumnType.USERID), - Column(name="etag", column_type=ColumnType.STRING, maximum_size=64), - Column(name="type", column_type=ColumnType.STRING, maximum_size=64), - Column(name="parentId", column_type=ColumnType.ENTITYID), - Column(name="benefactorId", column_type=ColumnType.ENTITYID), - Column(name="projectId", column_type=ColumnType.ENTITYID), - Column(name="modifiedOn", column_type=ColumnType.DATE), - Column(name="modifiedBy", column_type=ColumnType.USERID), - Column(name="dataFileHandleId", column_type=ColumnType.FILEHANDLEID), - ] - - entity_view = EntityView( - name=str(uuid.uuid4()), - parent_id=project_model.id, - scope_ids=[folder.id], - view_type_mask=ViewTypeMask.FILE.value, - columns=columns, - ).store(synapse_client=self.syn) - self.schedule_for_cleanup(entity_view.id) - - return folder, entity_view - - @pytest.fixture(scope="function") - def record_set(self, project_model: Project) -> RecordSet: - """Create a RecordSet for record-based testing.""" - folder = Folder( - name=str(uuid.uuid4()), - parent_id=project_model.id, - ).store(synapse_client=self.syn) - self.schedule_for_cleanup(folder.id) - - # Create test data as a pandas DataFrame - test_data = pd.DataFrame( - { - "title": [ - "Pasta Carbonara", - "Chicken Tikka Masala", - "Beef Tacos", - "Sushi Roll", - "French Onion Soup", - ], - "regional_cuisine": [ - "Italian", - "Indian", - "Mexican", - "Japanese", - "French", - ], - "prep_time_minutes": [30, 45, 20, 60, 90], - "difficulty": ["Medium", "Hard", "Easy", "Hard", "Medium"], - "vegetarian": [False, False, False, False, True], - } - ) - - # Create a temporary CSV file - temp_fd, filename = tempfile.mkstemp(suffix=".csv") - try: - os.close(temp_fd) # Close the file descriptor - test_data.to_csv(filename, index=False) - self.schedule_for_cleanup(filename) - - record_set = RecordSet( - name=str(uuid.uuid4()), - parent_id=folder.id, - path=filename, - upsert_keys=["title", "regional_cuisine"], - ).store(synapse_client=self.syn) - self.schedule_for_cleanup(record_set.id) - - return record_set - except Exception: - # Clean up the temp file if something goes wrong - if os.path.exists(filename): - os.unlink(filename) - raise - - def test_store_file_based_curation_task( - self, team, project_model: Project, folder_with_view: tuple[Folder, EntityView] - ) -> None: - # GIVEN a project, folder, and entity view - folder, entity_view = folder_with_view - - # AND a FileBasedMetadataTaskProperties - task_properties = FileBasedMetadataTaskProperties( - upload_folder_id=folder.id, - file_view_id=entity_view.id, - ) - - # AND a CurationTask - data_type = f"test_data_type_{str(uuid.uuid4()).replace('-', '_')}" - curation_task = CurationTask( - data_type=data_type, - project_id=project_model.id, - instructions="Please curate this test data.", - task_properties=task_properties, - assignee_principal_id=str(team.id), - ) - - # WHEN I store the curation task - stored_task = curation_task.store(synapse_client=self.syn) - - # THEN the task should be stored successfully - assert stored_task.task_id is not None - assert stored_task.data_type == data_type - assert stored_task.project_id == project_model.id - assert stored_task.instructions == "Please curate this test data." - assert isinstance(stored_task.task_properties, FileBasedMetadataTaskProperties) - assert stored_task.task_properties.upload_folder_id == folder.id - assert stored_task.task_properties.file_view_id == entity_view.id - assert stored_task.etag is not None - assert stored_task.created_on is not None - assert stored_task.created_by is not None - assert stored_task.assignee_principal_id == str(team.id) - - def test_store_record_based_curation_task( - self, project_model: Project, record_set: RecordSet, team: Team - ) -> None: - # GIVEN a project and record set - # AND a RecordBasedMetadataTaskProperties - task_properties = RecordBasedMetadataTaskProperties( - record_set_id=record_set.id, - ) - - # AND a CurationTask - data_type = f"test_data_type_{str(uuid.uuid4()).replace('-', '_')}" - curation_task = CurationTask( - data_type=data_type, - project_id=project_model.id, - instructions="Please curate this record-based test data.", - task_properties=task_properties, - assignee_principal_id=str(team.id), - ) - - # WHEN I store the curation task - stored_task = curation_task.store(synapse_client=self.syn) - - # THEN the task should be stored successfully - assert stored_task.task_id is not None - assert stored_task.data_type == data_type - assert stored_task.project_id == project_model.id - assert stored_task.instructions == "Please curate this record-based test data." - assert isinstance( - stored_task.task_properties, RecordBasedMetadataTaskProperties - ) - assert stored_task.task_properties.record_set_id == record_set.id - assert stored_task.etag is not None - assert stored_task.created_on is not None - assert stored_task.created_by is not None - assert stored_task.assignee_principal_id == str(team.id) - - def test_store_update_existing_curation_task( - self, project_model: Project, record_set: RecordSet - ) -> None: - # GIVEN an existing curation task - data_type = f"test_data_type_{str(uuid.uuid4()).replace('-', '_')}" - original_properties = RecordBasedMetadataTaskProperties( - record_set_id=record_set.id - ) - original_task = CurationTask( - data_type=data_type, - project_id=project_model.id, - instructions="Original instructions", - task_properties=original_properties, - ).store(synapse_client=self.syn) - - # WHEN I create a new task with the same data_type and project_id but different instructions - updated_task = CurationTask( - data_type=data_type, - project_id=project_model.id, - instructions="Updated instructions", - ) - - stored_updated_task = updated_task.store(synapse_client=self.syn) - - # THEN the existing task should be updated - assert stored_updated_task.task_id == original_task.task_id - assert stored_updated_task.instructions == "Updated instructions" - assert stored_updated_task.data_type == data_type - assert stored_updated_task.project_id == project_model.id - # The task_properties should be preserved from the original task - assert isinstance( - stored_updated_task.task_properties, RecordBasedMetadataTaskProperties - ) - assert stored_updated_task.task_properties.record_set_id == record_set.id - - def test_store_validation_errors(self) -> None: - # GIVEN a CurationTask without required fields - curation_task = CurationTask() - - # WHEN I try to store it without project_id - # THEN it should raise a ValueError - with pytest.raises(ValueError, match="project_id is required"): - curation_task.store(synapse_client=self.syn) - - # AND WHEN I provide project_id but not data_type - curation_task.project_id = "syn123" - with pytest.raises(ValueError, match="data_type is required"): - curation_task.store(synapse_client=self.syn) - - -class TestCurationTaskGet: - """Tests for the CurationTask.get method.""" - - @pytest.fixture(autouse=True, scope="function") - def init(self, syn: Synapse, schedule_for_cleanup: Callable[..., None]) -> None: - self.syn = syn - self.schedule_for_cleanup = schedule_for_cleanup - - @pytest.fixture(scope="function") - def folder_with_view(self, project_model: Project) -> tuple[Folder, EntityView]: - """Create a folder with an associated EntityView for file-based testing.""" - # Create a folder - folder = Folder( - name=str(uuid.uuid4()), - parent_id=project_model.id, - ).store(synapse_client=self.syn) - self.schedule_for_cleanup(folder.id) - - # Create required columns for the EntityView - columns = [ - Column(name="id", column_type=ColumnType.ENTITYID), - Column(name="name", column_type=ColumnType.STRING, maximum_size=256), - Column(name="createdOn", column_type=ColumnType.DATE), - Column(name="createdBy", column_type=ColumnType.USERID), - Column(name="etag", column_type=ColumnType.STRING, maximum_size=64), - Column(name="type", column_type=ColumnType.STRING, maximum_size=64), - Column(name="parentId", column_type=ColumnType.ENTITYID), - Column(name="benefactorId", column_type=ColumnType.ENTITYID), - Column(name="projectId", column_type=ColumnType.ENTITYID), - Column(name="modifiedOn", column_type=ColumnType.DATE), - Column(name="modifiedBy", column_type=ColumnType.USERID), - Column(name="dataFileHandleId", column_type=ColumnType.FILEHANDLEID), - ] - - entity_view = EntityView( - name=str(uuid.uuid4()), - parent_id=project_model.id, - scope_ids=[folder.id], - view_type_mask=ViewTypeMask.FILE.value, - columns=columns, - ).store(synapse_client=self.syn) - self.schedule_for_cleanup(entity_view.id) - - return folder, entity_view - - def test_get_curation_task( - self, project_model: Project, folder_with_view: tuple[Folder, EntityView] - ) -> None: - # GIVEN a project, folder, and entity view - folder, entity_view = folder_with_view - - # GIVEN an existing curation task - data_type = f"test_data_type_{str(uuid.uuid4()).replace('-', '_')}" - task_properties = FileBasedMetadataTaskProperties( - upload_folder_id=folder.id, - file_view_id=entity_view.id, - ) - original_task = CurationTask( - data_type=data_type, - project_id=project_model.id, - instructions="Test instructions", - task_properties=task_properties, - ).store(synapse_client=self.syn) - - # WHEN I get the task by ID - retrieved_task = CurationTask(task_id=original_task.task_id).get( - synapse_client=self.syn - ) - - # THEN the retrieved task should match the original - assert retrieved_task.task_id == original_task.task_id - assert retrieved_task.data_type == data_type - assert retrieved_task.project_id == project_model.id - assert retrieved_task.instructions == "Test instructions" - assert isinstance( - retrieved_task.task_properties, FileBasedMetadataTaskProperties - ) - assert retrieved_task.task_properties.upload_folder_id == folder.id - assert retrieved_task.task_properties.file_view_id == entity_view.id - assert retrieved_task.etag == original_task.etag - assert retrieved_task.created_on == original_task.created_on - assert retrieved_task.created_by == original_task.created_by - - def test_get_validation_error(self) -> None: - # GIVEN a CurationTask without a task_id - curation_task = CurationTask() - - # WHEN I try to get it - # THEN it should raise a ValueError - with pytest.raises( - ValueError, match="task_id is required to get a CurationTask" - ): - curation_task.get(synapse_client=self.syn) - - def test_get_non_existent_task(self) -> None: - # GIVEN a non-existent task ID - curation_task = CurationTask(task_id=999999) - - # WHEN I try to get it - # THEN it should raise a SynapseHTTPError - with pytest.raises(SynapseHTTPError): - curation_task.get(synapse_client=self.syn) - - -class TestCurationTaskDelete: - """Tests for the CurationTask.delete method.""" - - @pytest.fixture(autouse=True, scope="function") - def init(self, syn: Synapse, schedule_for_cleanup: Callable[..., None]) -> None: - self.syn = syn - self.schedule_for_cleanup = schedule_for_cleanup - - @pytest.fixture(scope="function") - def folder_with_view(self, project_model: Project) -> tuple[Folder, EntityView]: - """Create a folder with an associated EntityView for file-based testing.""" - # Create a folder - folder = Folder( - name=str(uuid.uuid4()), - parent_id=project_model.id, - ).store(synapse_client=self.syn) - self.schedule_for_cleanup(folder.id) - - # Create required columns for the EntityView - columns = [ - Column(name="id", column_type=ColumnType.ENTITYID), - Column(name="name", column_type=ColumnType.STRING, maximum_size=256), - Column(name="createdOn", column_type=ColumnType.DATE), - Column(name="createdBy", column_type=ColumnType.USERID), - Column(name="etag", column_type=ColumnType.STRING, maximum_size=64), - Column(name="type", column_type=ColumnType.STRING, maximum_size=64), - Column(name="parentId", column_type=ColumnType.ENTITYID), - Column(name="benefactorId", column_type=ColumnType.ENTITYID), - Column(name="projectId", column_type=ColumnType.ENTITYID), - Column(name="modifiedOn", column_type=ColumnType.DATE), - Column(name="modifiedBy", column_type=ColumnType.USERID), - Column(name="dataFileHandleId", column_type=ColumnType.FILEHANDLEID), - ] - - entity_view = EntityView( - name=str(uuid.uuid4()), - parent_id=project_model.id, - scope_ids=[folder.id], - view_type_mask=ViewTypeMask.FILE.value, - columns=columns, - ).store(synapse_client=self.syn) - self.schedule_for_cleanup(entity_view.id) - - return folder, entity_view - - def test_delete_curation_task( - self, project_model: Project, folder_with_view: tuple[Folder, EntityView] - ) -> None: - # GIVEN a project, folder, and entity view - folder, entity_view = folder_with_view - - # GIVEN an existing curation task - data_type = f"test_data_type_{str(uuid.uuid4()).replace('-', '_')}" - task_properties = FileBasedMetadataTaskProperties( - upload_folder_id=folder.id, - file_view_id=entity_view.id, - ) - curation_task = CurationTask( - data_type=data_type, - project_id=project_model.id, - instructions="Task to be deleted", - task_properties=task_properties, - ).store(synapse_client=self.syn) - - task_id = curation_task.task_id - assert task_id is not None - - # WHEN I delete the task - curation_task.delete(synapse_client=self.syn) - - # THEN the task should be deleted and no longer retrievable - with pytest.raises(SynapseHTTPError): - CurationTask(task_id=task_id).get(synapse_client=self.syn) - - def test_delete_validation_error(self) -> None: - # GIVEN a CurationTask without a task_id - curation_task = CurationTask() - - # WHEN I try to delete it - # THEN it should raise a ValueError - with pytest.raises( - ValueError, match="task_id is required to delete a CurationTask" - ): - curation_task.delete(synapse_client=self.syn) - - -class TestCurationTaskList: - """Tests for the CurationTask.list method.""" - - @pytest.fixture(autouse=True, scope="function") - def init(self, syn: Synapse, schedule_for_cleanup: Callable[..., None]) -> None: - self.syn = syn - self.schedule_for_cleanup = schedule_for_cleanup - - @pytest.fixture(scope="function") - def folder_with_view(self, project_model: Project) -> tuple[Folder, EntityView]: - """Create a folder with an associated EntityView for file-based testing.""" - # Create a folder - folder = Folder( - name=str(uuid.uuid4()), - parent_id=project_model.id, - ).store(synapse_client=self.syn) - self.schedule_for_cleanup(folder.id) - - # Create required columns for the EntityView - columns = [ - Column(name="id", column_type=ColumnType.ENTITYID), - Column(name="name", column_type=ColumnType.STRING, maximum_size=256), - Column(name="createdOn", column_type=ColumnType.DATE), - Column(name="createdBy", column_type=ColumnType.USERID), - Column(name="etag", column_type=ColumnType.STRING, maximum_size=64), - Column(name="type", column_type=ColumnType.STRING, maximum_size=64), - Column(name="parentId", column_type=ColumnType.ENTITYID), - Column(name="benefactorId", column_type=ColumnType.ENTITYID), - Column(name="projectId", column_type=ColumnType.ENTITYID), - Column(name="modifiedOn", column_type=ColumnType.DATE), - Column(name="modifiedBy", column_type=ColumnType.USERID), - Column(name="dataFileHandleId", column_type=ColumnType.FILEHANDLEID), - ] - - entity_view = EntityView( - name=str(uuid.uuid4()), - parent_id=project_model.id, - scope_ids=[folder.id], - view_type_mask=ViewTypeMask.FILE.value, - columns=columns, - ).store(synapse_client=self.syn) - self.schedule_for_cleanup(entity_view.id) - - return folder, entity_view - - def test_list_curation_tasks( - self, project_model: Project, folder_with_view: tuple[Folder, EntityView] - ) -> None: - # GIVEN a project, folder, and entity view - folder, entity_view = folder_with_view - - # GIVEN multiple curation tasks in a project - tasks_data = [] - for i in range(3): - data_type = f"test_data_type_{i}_{str(uuid.uuid4()).replace('-', '_')}" - task_properties = FileBasedMetadataTaskProperties( - upload_folder_id=folder.id, - file_view_id=entity_view.id, - ) - task = CurationTask( - data_type=data_type, - project_id=project_model.id, - instructions=f"Instructions for task {i}", - task_properties=task_properties, - ).store(synapse_client=self.syn) - tasks_data.append((data_type, task.task_id)) - - # WHEN I list all curation tasks for the project - listed_tasks = list( - CurationTask.list(project_id=project_model.id, synapse_client=self.syn) - ) - - # THEN I should get all the created tasks - assert len(listed_tasks) >= 3 # There might be other tasks from other tests - - # Check that our created tasks are in the list - listed_task_ids = [task.task_id for task in listed_tasks] - listed_data_types = [task.data_type for task in listed_tasks] - - for data_type, task_id in tasks_data: - assert task_id in listed_task_ids - assert data_type in listed_data_types - - # Verify the structure of retrieved tasks - for task in listed_tasks: - if task.task_id in [t[1] for t in tasks_data]: - assert task.project_id == project_model.id - assert task.task_properties is not None - assert task.etag is not None - assert task.created_on is not None - assert task.created_by is not None - - def test_list_empty_project(self) -> None: - # GIVEN a project with no curation tasks - empty_project = Project(name=str(uuid.uuid4())).store(synapse_client=self.syn) - self.schedule_for_cleanup(empty_project.id) - - # WHEN I list curation tasks for the project - listed_tasks = list( - CurationTask.list(project_id=empty_project.id, synapse_client=self.syn) - ) - - # THEN I should get an empty list - assert len(listed_tasks) == 0 diff --git a/tests/integration/synapseclient/models/synchronous/test_dataset.py b/tests/integration/synapseclient/models/synchronous/test_dataset.py deleted file mode 100644 index ddbb59703..000000000 --- a/tests/integration/synapseclient/models/synchronous/test_dataset.py +++ /dev/null @@ -1,656 +0,0 @@ -import uuid -from typing import Callable, List, Optional - -import pandas as pd -import pytest - -from synapseclient import Synapse -from synapseclient.core import utils -from synapseclient.core.exceptions import SynapseHTTPError -from synapseclient.models import ( - Column, - ColumnType, - Dataset, - DatasetCollection, - EntityRef, - File, - Folder, - Project, -) - -CONTENT_TYPE = "text/plain" -DESCRIPTION_FILE = "This is an example file." -DESCRIPTION_FOLDER = "This is an example folder." -DERSCRIPTION_PROJECT = "This is an example project." - -DEFAULT_COLUMNS = [ - "id", - "name", - "description", - "createdOn", - "createdBy", - "etag", - "modifiedOn", - "modifiedBy", - "path", - "type", - "currentVersion", - "parentId", - "benefactorId", - "projectId", - "dataFileHandleId", - "dataFileName", - "dataFileSizeBytes", - "dataFileMD5Hex", - "dataFileConcreteType", - "dataFileBucket", - "dataFileKey", -] - - -class TestDataset: - """Integration tests for Dataset functionality.""" - - @pytest.fixture(autouse=True, scope="function") - def init(self, syn: Synapse, schedule_for_cleanup: Callable[..., None]) -> None: - self.syn = syn - self.schedule_for_cleanup = schedule_for_cleanup - - def create_file_instance(self) -> File: - """Helper to create a file instance""" - filename = utils.make_bogus_uuid_file() - self.schedule_for_cleanup(filename) - return File( - path=filename, - description=DESCRIPTION_FILE, - content_type=CONTENT_TYPE, - ) - - def create_dataset_with_items( - self, - project_model: Project, - files: Optional[List[File]] = None, - folders: Optional[List[Folder]] = None, - name: Optional[str] = None, - description: str = "Test dataset", - columns: Optional[List[Column]] = None, - ) -> Dataset: - """Helper to create a dataset with optional items""" - dataset = Dataset( - name=name or str(uuid.uuid4()), - description=description, - parent_id=project_model.id, - columns=columns or [], - ) - - # Add files if provided - if files: - for file in files: - stored_file = file.store(parent=project_model, synapse_client=self.syn) - dataset.add_item(stored_file) - - # Add folders if provided - if folders: - for folder in folders: - stored_folder = folder.store( - parent=project_model, synapse_client=self.syn - ) - dataset.add_item(stored_folder) - - # Store the dataset - dataset = dataset.store(synapse_client=self.syn) - self.schedule_for_cleanup(dataset.id) - - return dataset - - def test_dataset_basic_operations(self, project_model: Project) -> None: - """Test dataset creation, retrieval, updating and deletion""" - # GIVEN a name and description for a dataset - dataset_name = str(uuid.uuid4()) - dataset_description = "Test dataset basic operations" - - # WHEN I create an empty dataset - dataset = Dataset( - name=dataset_name, - description=dataset_description, - parent_id=project_model.id, - ) - dataset = dataset.store(synapse_client=self.syn) - self.schedule_for_cleanup(dataset.id) - - # THEN the dataset should be created with an ID - assert dataset.id is not None - - # WHEN I retrieve the dataset - retrieved_dataset = Dataset(id=dataset.id).get(synapse_client=self.syn) - - # THEN it should have the expected properties - assert retrieved_dataset is not None - assert retrieved_dataset.name == dataset_name - assert retrieved_dataset.id == dataset.id - assert retrieved_dataset.description == dataset_description - - # WHEN I update the dataset attributes - updated_name = str(uuid.uuid4()) - updated_description = "Updated description" - dataset.name = updated_name - dataset.description = updated_description - dataset.store(synapse_client=self.syn) - - # THEN the updates should be reflected when retrieved - retrieved_updated = Dataset(id=dataset.id).get(synapse_client=self.syn) - assert retrieved_updated.name == updated_name - assert retrieved_updated.description == updated_description - assert retrieved_updated.id == dataset.id # ID remains the same - - # WHEN I delete the dataset - dataset.delete(synapse_client=self.syn) - - # THEN it should no longer be accessible - with pytest.raises( - SynapseHTTPError, - match=f"404 Client Error: Entity {dataset.id} is in trash can.", - ): - Dataset(id=dataset.id).get(synapse_client=self.syn) - - def test_dataset_with_items(self, project_model: Project) -> None: - """Test creating and managing a dataset with various items (files, folders)""" - # GIVEN 3 files and a folder with 2 files - files = [self.create_file_instance() for _ in range(3)] - folder = Folder(name=str(uuid.uuid4()), description=DESCRIPTION_FOLDER) - folder_files = [self.create_file_instance() for _ in range(2)] - - # WHEN I store the files and folder - stored_files = [] - for file in files: - stored_file = file.store(parent=project_model, synapse_client=self.syn) - stored_files.append(stored_file) - - folder.files = folder_files - stored_folder = folder.store(parent=project_model, synapse_client=self.syn) - - # AND create a dataset with these items - dataset = Dataset( - name=str(uuid.uuid4()), - description="Test dataset with items", - parent_id=project_model.id, - ) - - # Add individual files - for file in stored_files: - dataset.add_item(file, synapse_client=self.syn) - - # Add folder - dataset.add_item(stored_folder, synapse_client=self.syn) - - # Store the dataset - dataset = dataset.store(synapse_client=self.syn) - self.schedule_for_cleanup(dataset.id) - - # THEN the dataset should contain all expected items - retrieved_dataset = Dataset(id=dataset.id).get(synapse_client=self.syn) - - # Verify dataset has all expected files - expected_items = [ - EntityRef(id=file.id, version=file.version_number) for file in stored_files - ] + [ - EntityRef(id=file.id, version=file.version_number) - for file in stored_folder.files - ] - - assert len(retrieved_dataset.items) == len(expected_items) - for item in expected_items: - assert item in retrieved_dataset.items - - # WHEN I remove one file from the dataset - dataset.remove_item(stored_files[0], synapse_client=self.syn) - dataset.store(synapse_client=self.syn) - - # THEN that file should no longer be in the dataset - updated_dataset = Dataset(id=dataset.id).get(synapse_client=self.syn) - assert ( - EntityRef(id=stored_files[0].id, version=stored_files[0].version_number) - not in updated_dataset.items - ) - assert len(updated_dataset.items) == len(expected_items) - 1 - - def test_dataset_query_operations(self, project_model: Project) -> None: - """Test querying a dataset and different query modes""" - # GIVEN a dataset with a file and custom column - file = self.create_file_instance() - stored_file = file.store(parent=project_model, synapse_client=self.syn) - - dataset = Dataset( - name=str(uuid.uuid4()), - description="Test dataset for queries", - parent_id=project_model.id, - columns=[Column(name="my_annotation", column_type=ColumnType.STRING)], - ) - dataset.add_item(stored_file, synapse_client=self.syn) - dataset = dataset.store(synapse_client=self.syn) - self.schedule_for_cleanup(dataset.id) - - # WHEN I update rows in the dataset - modified_data = pd.DataFrame( - { - "id": [stored_file.id], - "my_annotation": ["test_value"], - } - ) - dataset.update_rows( - values=modified_data, - primary_keys=["id"], - wait_for_eventually_consistent_view=True, - dry_run=False, - synapse_client=self.syn, - ) - - # THEN I can query the data - row = Dataset.query( - query=f"SELECT * FROM {dataset.id} WHERE id = '{stored_file.id}'", - synapse_client=self.syn, - ) - - # AND the query results should match the expected values - assert row["id"][0] == stored_file.id - assert row["name"][0] == stored_file.name - assert row["description"][0] == stored_file.description - assert row["my_annotation"][0] == "test_value" - - # WHEN I use part_mask to query with additional information - QUERY_RESULTS = 0x1 - QUERY_COUNT = 0x2 - SUM_FILE_SIZE_BYTES = 0x40 - LAST_UPDATED_ON = 0x80 - part_mask = QUERY_RESULTS | QUERY_COUNT | SUM_FILE_SIZE_BYTES | LAST_UPDATED_ON - - results = Dataset.query_part_mask( - query=f"SELECT * FROM {dataset.id}", - synapse_client=self.syn, - part_mask=part_mask, - ) - - # THEN all requested parts should be included in the result - assert results.result["id"][0] == stored_file.id - assert results.count == 1 - assert results.sum_file_sizes is not None - assert results.last_updated_on is not None - - # WHEN I query with only results requested - results_only = Dataset.query_part_mask( - query=f"SELECT * FROM {dataset.id}", - synapse_client=self.syn, - part_mask=QUERY_RESULTS, - ) - - # THEN only the results should be included (not count, sum_file_sizes, or last_updated_on) - assert results_only.result["id"][0] == stored_file.id - assert results_only.count is None - assert results_only.sum_file_sizes is None - assert results_only.last_updated_on is None - - def test_dataset_column_operations(self, project_model: Project) -> None: - """Test operations on dataset columns: add, rename, reorder, delete""" - # GIVEN a dataset with no custom columns - dataset = self.create_dataset_with_items(project_model) - - # WHEN I add a column to the dataset - column_name = "test_column" - dataset.add_column( - column=Column(name=column_name, column_type=ColumnType.STRING) - ) - dataset.store(synapse_client=self.syn) - - # THEN the column should be present in the dataset - updated_dataset = Dataset(id=dataset.id).get(synapse_client=self.syn) - assert column_name in updated_dataset.columns - - # WHEN I add a second column and rename the first - second_column = "second_column" - dataset.add_column( - column=Column(name=second_column, column_type=ColumnType.INTEGER) - ) - new_name = "renamed_column" - dataset.columns[column_name].name = new_name - dataset.store(synapse_client=self.syn) - - # THEN the columns should reflect these changes - updated_dataset = Dataset(id=dataset.id).get(synapse_client=self.syn) - assert new_name in updated_dataset.columns - assert second_column in updated_dataset.columns - assert column_name not in updated_dataset.columns - - # WHEN I reorder the columns - dataset.reorder_column(name=second_column, index=0) - dataset.store(synapse_client=self.syn) - - # THEN the columns should be in the new order - updated_dataset = Dataset(id=dataset.id).get(synapse_client=self.syn) - column_keys = [ - k for k in updated_dataset.columns.keys() if k not in DEFAULT_COLUMNS - ] - assert column_keys[0] == second_column - assert column_keys[1] == new_name - - # WHEN I delete a column - dataset.delete_column(name=second_column) - dataset.store(synapse_client=self.syn) - - # THEN the column should be removed - updated_dataset = Dataset(id=dataset.id).get(synapse_client=self.syn) - assert second_column not in updated_dataset.columns - assert new_name in updated_dataset.columns - - def test_dataset_versioning(self, project_model: Project) -> None: - """Test creating snapshots and versioning of datasets""" - # GIVEN a dataset and two files - file1 = self.create_file_instance() - file2 = self.create_file_instance() - - file1 = file1.store(parent=project_model, synapse_client=self.syn) - file2 = file2.store(parent=project_model, synapse_client=self.syn) - - dataset = Dataset( - name=str(uuid.uuid4()), - description="Test dataset versioning", - parent_id=project_model.id, - ) - dataset = dataset.store(synapse_client=self.syn) - self.schedule_for_cleanup(dataset.id) - - # WHEN I add the first file and create a snapshot - dataset.add_item(file1, synapse_client=self.syn) - dataset.store(synapse_client=self.syn) - dataset.snapshot(synapse_client=self.syn) - - # AND I add the second file and create another snapshot - dataset.add_item(file2, synapse_client=self.syn) - dataset.store(synapse_client=self.syn) - dataset.snapshot(synapse_client=self.syn) - - # THEN version 1 should only contain the first file - dataset_v1 = Dataset(id=dataset.id, version_number=1).get( - synapse_client=self.syn - ) - assert len(dataset_v1.items) == 1 - assert dataset_v1.items[0] == EntityRef( - id=file1.id, version=file1.version_number - ) - - # AND version 2 should contain both files - dataset_v2 = Dataset(id=dataset.id, version_number=2).get( - synapse_client=self.syn - ) - assert len(dataset_v2.items) == 2 - assert EntityRef(id=file1.id, version=file1.version_number) in dataset_v2.items - assert EntityRef(id=file2.id, version=file2.version_number) in dataset_v2.items - - -class TestDatasetCollection: - """Integration tests for DatasetCollection functionality.""" - - @pytest.fixture(autouse=True, scope="function") - def init(self, syn: Synapse, schedule_for_cleanup: Callable[..., None]) -> None: - self.syn = syn - self.schedule_for_cleanup = schedule_for_cleanup - - def create_file_instance(self) -> File: - """Helper to create a file instance""" - filename = utils.make_bogus_uuid_file() - self.schedule_for_cleanup(filename) - return File( - path=filename, - description=DESCRIPTION_FILE, - content_type=CONTENT_TYPE, - ) - - def create_dataset(self, project_model: Project, has_file: bool = False) -> Dataset: - """Helper to create a dataset""" - dataset = Dataset( - name=str(uuid.uuid4()), - description="Test dataset", - parent_id=project_model.id, - ) - - if has_file: - file = self.create_file_instance() - stored_file = file.store(parent=project_model, synapse_client=self.syn) - dataset.add_item(stored_file, synapse_client=self.syn) - - dataset = dataset.store(synapse_client=self.syn) - self.schedule_for_cleanup(dataset.id) - return dataset - - def test_dataset_collection_lifecycle(self, project_model: Project) -> None: - """Test creating, updating, and deleting a DatasetCollection""" - # GIVEN two datasets - dataset1 = self.create_dataset(project_model, has_file=True) - dataset2 = self.create_dataset(project_model, has_file=True) - - # WHEN I create a DatasetCollection with the first dataset - collection = DatasetCollection( - name=str(uuid.uuid4()), - description="Test collection", - parent_id=project_model.id, - ) - collection.add_item(dataset1) - collection = collection.store(synapse_client=self.syn) - self.schedule_for_cleanup(collection.id) - - # THEN the collection should be created and contain the dataset - assert collection.id is not None - assert collection.items == [ - EntityRef(id=dataset1.id, version=dataset1.version_number) - ] - - # WHEN I retrieve the collection - retrieved = DatasetCollection(id=collection.id).get(synapse_client=self.syn) - - # THEN it should match the original - assert retrieved.id == collection.id - assert retrieved.name == collection.name - assert retrieved.description == collection.description - assert retrieved.items == collection.items - - # WHEN I update the collection attributes and add another dataset - new_name = str(uuid.uuid4()) - new_description = "Updated description" - collection.name = new_name - collection.description = new_description - collection.add_item(dataset2) - collection.store(synapse_client=self.syn) - - # THEN the updates should be reflected - updated = DatasetCollection(id=collection.id).get(synapse_client=self.syn) - assert updated.name == new_name - assert updated.description == new_description - assert len(updated.items) == 2 - assert ( - EntityRef(id=dataset1.id, version=dataset1.version_number) in updated.items - ) - assert ( - EntityRef(id=dataset2.id, version=dataset2.version_number) in updated.items - ) - - # WHEN I delete the collection - collection.delete(synapse_client=self.syn) - - # THEN it should no longer be accessible - with pytest.raises( - SynapseHTTPError, - match=f"404 Client Error: Entity {collection.id} is in trash can.", - ): - DatasetCollection(id=collection.id).get(synapse_client=self.syn) - - def test_dataset_collection_queries(self, project_model: Project) -> None: - """Test querying DatasetCollections with various part masks""" - # GIVEN a dataset and a collection with that dataset - dataset = self.create_dataset(project_model, has_file=True) - - collection = DatasetCollection( - name=str(uuid.uuid4()), - description="Test collection for queries", - parent_id=project_model.id, - columns=[Column(name="my_annotation", column_type=ColumnType.STRING)], - ) - collection.add_item(dataset) - collection = collection.store(synapse_client=self.syn) - self.schedule_for_cleanup(collection.id) - - # WHEN I add annotations via row updates - modified_data = pd.DataFrame( - { - "id": [dataset.id], - "my_annotation": ["collection_value"], - } - ) - collection.update_rows( - values=modified_data, - primary_keys=["id"], - wait_for_eventually_consistent_view=True, - dry_run=False, - synapse_client=self.syn, - ) - - # THEN I can query and get the updated data - row = DatasetCollection.query( - query=f"SELECT * FROM {collection.id} WHERE id = '{dataset.id}'", - synapse_client=self.syn, - ) - assert row["id"][0] == dataset.id - assert row["name"][0] == dataset.name - assert row["my_annotation"][0] == "collection_value" - - # WHEN I query with a part mask with all parts - QUERY_RESULTS = 0x1 - QUERY_COUNT = 0x2 - SUM_FILE_SIZE_BYTES = 0x40 - LAST_UPDATED_ON = 0x80 - part_mask = QUERY_RESULTS | QUERY_COUNT | SUM_FILE_SIZE_BYTES | LAST_UPDATED_ON - - row = DatasetCollection.query_part_mask( - query=f"SELECT * FROM {collection.id}", - synapse_client=self.syn, - part_mask=part_mask, - ) - - # THEN I expect the row to contain expected values - assert row.result["id"][0] == dataset.id - assert row.result["name"][0] == dataset.name - assert row.result["description"][0] == dataset.description - - # AND the part mask should be reflected in the row - assert row.count == 1 - assert row.sum_file_sizes is not None - assert row.sum_file_sizes.greater_than is not None - assert row.sum_file_sizes.sum_file_size_bytes is not None - assert row.last_updated_on is not None - - # WHEN I query with only results - results_only = DatasetCollection.query_part_mask( - query=f"SELECT * FROM {collection.id}", - part_mask=QUERY_RESULTS, - synapse_client=self.syn, - ) - # THEN the data in the columns should match - assert results_only.result["id"][0] == dataset.id - assert results_only.result["name"][0] == dataset.name - assert results_only.result["description"][0] == dataset.description - - # AND the part mask should be reflected in the results - assert results_only.count is None - assert results_only.sum_file_sizes is None - assert results_only.last_updated_on is None - - def test_dataset_collection_columns(self, project_model: Project) -> None: - """Test column operations on DatasetCollections""" - # GIVEN a DatasetCollection - collection = DatasetCollection( - name=str(uuid.uuid4()), - parent_id=project_model.id, - ) - collection = collection.store(synapse_client=self.syn) - self.schedule_for_cleanup(collection.id) - - # WHEN I add columns to the collection - first_col = "first_column" - second_col = "second_column" - collection.add_column(Column(name=first_col, column_type=ColumnType.STRING)) - collection.add_column(Column(name=second_col, column_type=ColumnType.INTEGER)) - collection.store(synapse_client=self.syn) - - # THEN the columns should be in the collection - updated = DatasetCollection(id=collection.id).get(synapse_client=self.syn) - assert first_col in updated.columns - assert second_col in updated.columns - - # WHEN I reorder the columns - collection.reorder_column(name=second_col, index=0) - collection.reorder_column(name=first_col, index=1) - collection.store(synapse_client=self.syn) - - # THEN the columns should be in the new order - updated = DatasetCollection(id=collection.id).get(synapse_client=self.syn) - columns = [k for k in updated.columns.keys() if k not in DEFAULT_COLUMNS] - assert columns[0] == second_col - assert columns[1] == first_col - - # WHEN I rename a column - new_name = "renamed_column" - collection.columns[first_col].name = new_name - collection.store(synapse_client=self.syn) - - # THEN the column should have the new name - updated = DatasetCollection(id=collection.id).get(synapse_client=self.syn) - assert new_name in updated.columns - assert first_col not in updated.columns - - # WHEN I delete a column - collection.delete_column(name=second_col) - collection.store(synapse_client=self.syn) - - # THEN the column should be removed - updated = DatasetCollection(id=collection.id).get(synapse_client=self.syn) - assert second_col not in updated.columns - assert new_name in updated.columns - - def test_dataset_collection_versioning(self, project_model: Project) -> None: - """Test versioning of DatasetCollections""" - - # GIVEN a DatasetCollection and datasets - dataset1 = self.create_dataset(project_model) - dataset2 = self.create_dataset(project_model) - - collection = DatasetCollection( - name=str(uuid.uuid4()), - description="Original description", - parent_id=project_model.id, - ) - collection.add_item(dataset1) - collection = collection.store(synapse_client=self.syn) - self.schedule_for_cleanup(collection.id) - - # WHEN I create a snapshot of version 1 - collection.snapshot(synapse_client=self.syn) - - # AND I update the collection and make version 2 - collection.name = f"Updated collection {uuid.uuid4()}" - collection.add_item(dataset2) - collection.store(synapse_client=self.syn) - collection.snapshot(synapse_client=self.syn) - - # THEN version 1 should only contain the first dataset - v1 = DatasetCollection(id=collection.id, version_number=1).get( - synapse_client=self.syn - ) - assert len(v1.items) == 1 - assert v1.items[0] == EntityRef(id=dataset1.id, version=dataset1.version_number) - - # AND version 2 should contain both datasets and the updated name - v2 = DatasetCollection(id=collection.id, version_number=2).get( - synapse_client=self.syn - ) - assert len(v2.items) == 2 - assert v2.name == collection.name - assert EntityRef(id=dataset1.id, version=dataset1.version_number) in v2.items - assert EntityRef(id=dataset2.id, version=dataset2.version_number) in v2.items diff --git a/tests/integration/synapseclient/models/synchronous/test_entityview.py b/tests/integration/synapseclient/models/synchronous/test_entityview.py deleted file mode 100644 index da7b79196..000000000 --- a/tests/integration/synapseclient/models/synchronous/test_entityview.py +++ /dev/null @@ -1,655 +0,0 @@ -import asyncio -import tempfile -import uuid -from typing import Callable, List - -import pandas as pd -import pytest -from pytest_mock import MockerFixture - -import synapseclient.models.mixins.table_components as table_module -from synapseclient import Synapse -from synapseclient.api import get_default_columns -from synapseclient.core import utils -from synapseclient.core.exceptions import SynapseHTTPError -from synapseclient.models import ( - Activity, - Column, - ColumnType, - EntityView, - File, - Folder, - Project, - UsedURL, - ViewTypeMask, - query, - query_part_mask, -) - - -class TestEntityView: - """Integration tests for Entity View functionality.""" - - @pytest.fixture(autouse=True, scope="function") - def init(self, syn: Synapse, schedule_for_cleanup: Callable[..., None]) -> None: - self.syn = syn - self.schedule_for_cleanup = schedule_for_cleanup - - def setup_files_in_folder( - self, project_model: Project, num_files: int = 4 - ) -> tuple[Folder, List[File]]: - """Helper to create a folder with files for testing""" - # Create a folder - folder = Folder(name=str(uuid.uuid4()), parent_id=project_model.id).store( - synapse_client=self.syn - ) - self.schedule_for_cleanup(folder.id) - - # Create files - files = [] - filename = utils.make_bogus_uuid_file() - - # First file has a real path - file1 = File( - parent_id=folder.id, - name="file1", - path=filename, - description="file1_description", - ).store(synapse_client=self.syn) - self.schedule_for_cleanup(file1.id) - files.append(file1) - - # Other files reuse the file handle - for i in range(2, num_files + 1): - file = File( - parent_id=folder.id, - name=f"file{i}", - data_file_handle_id=file1.data_file_handle_id, - description=f"file{i}_description", - ).store(synapse_client=self.syn) - self.schedule_for_cleanup(file.id) - files.append(file) - - return folder, files - - def test_entityview_creation_with_columns(self, project_model: Project) -> None: - """Test creating entity views with different column configurations""" - # GIVEN parameters for three different entity view configurations - test_cases = [ - { - "name": "default_columns", - "description": "EntityView with default columns", - "columns": None, - "include_default_columns": True, - "expected_column_count": None, # Will be set after getting default columns - }, - { - "name": "single_column", - "description": "EntityView with a single column", - "columns": [Column(name="test_column", column_type=ColumnType.STRING)], - "include_default_columns": False, - "expected_column_count": 1, - }, - { - "name": "multiple_columns", - "description": "EntityView with multiple columns", - "columns": [ - Column(name="test_column", column_type=ColumnType.STRING), - Column(name="test_column2", column_type=ColumnType.INTEGER), - ], - "include_default_columns": False, - "expected_column_count": 2, - }, - ] - - # Get default column count to set expectation - default_columns = asyncio.run( - get_default_columns( - view_type_mask=ViewTypeMask.FILE.value, synapse_client=self.syn - ) - ) - test_cases[0]["expected_column_count"] = len(default_columns) - - # Test each case - for case in test_cases: - # WHEN I create and store an entity view with the specified configuration - entityview = EntityView( - name=f"{case['name']}_{str(uuid.uuid4())}", - parent_id=project_model.id, - description=case["description"], - columns=case["columns"], - view_type_mask=ViewTypeMask.FILE, - include_default_columns=case["include_default_columns"], - ) - entityview = entityview.store(synapse_client=self.syn) - self.schedule_for_cleanup(entityview.id) - - # THEN the entity view should be created with correct properties - retrieved_view = EntityView(id=entityview.id).get( - synapse_client=self.syn, include_columns=True - ) - - # Verify basic properties - assert retrieved_view.id == entityview.id - assert retrieved_view.name == entityview.name - assert retrieved_view.description == entityview.description - - # Verify columns - assert len(retrieved_view.columns) == case["expected_column_count"] - - if case["name"] == "default_columns": - # Verify default columns - for column in default_columns: - assert column.name in retrieved_view.columns - assert column == retrieved_view.columns[column.name] - elif case["name"] == "single_column": - # Verify single column - assert "test_column" in retrieved_view.columns - assert ( - retrieved_view.columns["test_column"].column_type - == ColumnType.STRING - ) - elif case["name"] == "multiple_columns": - # Verify multiple columns - assert "test_column" in retrieved_view.columns - assert "test_column2" in retrieved_view.columns - assert ( - retrieved_view.columns["test_column"].column_type - == ColumnType.STRING - ) - assert ( - retrieved_view.columns["test_column2"].column_type - == ColumnType.INTEGER - ) - - def test_entityview_invalid_column(self, project_model: Project) -> None: - """Test creating an entity view with an invalid column""" - # GIVEN an entity view with an invalid column - entityview = EntityView( - name=str(uuid.uuid4()), - parent_id=project_model.id, - description="Test entityview with invalid column", - columns=[ - Column( - name="test_column", - column_type=ColumnType.STRING, - maximum_size=999999999, # Invalid: too large - ) - ], - view_type_mask=ViewTypeMask.FILE, - ) - - # WHEN I try to store the entity view - # THEN an exception should be raised - with pytest.raises(SynapseHTTPError) as e: - entityview.store(synapse_client=self.syn) - - assert ( - "400 Client Error: ColumnModel.maxSize for a STRING cannot exceed:" - in str(e.value) - ) - - def test_entityview_with_files_in_scope(self, project_model: Project) -> None: - """Test creating entity view with files in scope and querying it""" - # GIVEN a folder with files - folder, files = self.setup_files_in_folder(project_model) - - # WHEN I create an entity view with that folder in its scope - entityview = EntityView( - name=str(uuid.uuid4()), - parent_id=project_model.id, - view_type_mask=ViewTypeMask.FILE, - scope_ids=[folder.id], - ) - entityview = entityview.store(synapse_client=self.syn) - self.schedule_for_cleanup(entityview.id) - - # AND I query the data in the entity view - results = query(f"SELECT * FROM {entityview.id}", synapse_client=self.syn) - - # THEN the data for all files should be present in the view - assert len(results) == len(files) - - # AND the file properties should match - for i, file in enumerate(files): - assert results["name"][i] == file.name - assert results["description"][i] == file.description - - def test_update_rows_and_annotations( - self, mocker: MockerFixture, project_model: Project - ) -> None: - """Test updating rows in an entity view from different sources and verifying annotations""" - # GIVEN a folder with files - folder, files = self.setup_files_in_folder(project_model) - - # AND an entity view with columns and files in scope - entityview = EntityView( - name=str(uuid.uuid4()), - parent_id=project_model.id, - view_type_mask=ViewTypeMask.FILE.value, - scope_ids=[folder.id], - columns=[ - Column(name="column_string", column_type=ColumnType.STRING), - Column(name="integer_column", column_type=ColumnType.INTEGER), - Column(name="float_column", column_type=ColumnType.DOUBLE), - ], - ) - entityview = entityview.store(synapse_client=self.syn) - self.schedule_for_cleanup(entityview.id) - - # Custom wrapper to capture call stack - original_csv_to_pandas_df = table_module.csv_to_pandas_df - call_info = [] - - def csv_wrapper(*args, **kwargs): - import traceback - - stack = traceback.extract_stack() - - # Find the calling function (skip the wrapper itself) - calling_function = None - for frame in reversed(stack[:-1]): # Skip current frame - if "_upsert_rows_async" in frame.name: - calling_function = "_upsert_rows_async" - break - elif "query_async" in frame.name: - calling_function = "query_async" - break - else: - pass - - call_info.append( - { - "caller": calling_function, - "args": args, - "kwargs": kwargs, - "filepath": kwargs.get("filepath", args[0] if args else None), - } - ) - - return original_csv_to_pandas_df(*args, **kwargs) - - # Patch the csv_to_pandas_df function to use the wrapper - mock_csv_to_pandas_df = mocker.patch.object( - table_module, "csv_to_pandas_df", side_effect=csv_wrapper - ) - - # Create test data for all files - test_data = { - "id": [file.id for file in files], - "column_string": [f"value{i+1}" for i in range(len(files))], - "integer_column": [ - i + 1 if i < len(files) - 1 else None for i in range(len(files)) - ], - "float_column": [ - float(i + 1.1) if i < len(files) - 1 else None - for i in range(len(files)) - ], - } - - # Test three update methods: CSV, DataFrame, and dictionary - update_methods = ["csv", "dataframe", "dict"] - - for method in update_methods: - # Reset the spy for each method - call_info.clear() - - # WHEN I update rows using different input types - if method == "csv": - # Use CSV file - filepath = f"{tempfile.mkdtemp()}/upload_{uuid.uuid4()}.csv" - self.schedule_for_cleanup(filepath) - pd.DataFrame(test_data).to_csv( - filepath, index=False, float_format="%.12g" - ) - - entityview.update_rows( - values=filepath, - primary_keys=["id"], - synapse_client=self.syn, - wait_for_eventually_consistent_view=True, - ) - - # THEN the CSV conversion function should be called - _upsert_rows_async_calls = [ - call for call in call_info if call["caller"] == "_upsert_rows_async" - ] - assert len(_upsert_rows_async_calls) == 1 - - elif method == "dataframe": - # Use DataFrame - entityview.update_rows( - values=pd.DataFrame(test_data), - primary_keys=["id"], - synapse_client=self.syn, - wait_for_eventually_consistent_view=True, - ) - - # THEN the CSV conversion function should NOT be called - _upsert_rows_async_calls = [ - call for call in call_info if call["caller"] == "_upsert_rows_async" - ] - assert len(_upsert_rows_async_calls) == 0 - - else: # dict - # Use dictionary - entityview.update_rows( - values=test_data, - primary_keys=["id"], - synapse_client=self.syn, - wait_for_eventually_consistent_view=True, - ) - - # THEN the CSV conversion function should NOT be called - _upsert_rows_async_calls = [ - call for call in call_info if call["caller"] == "_upsert_rows_async" - ] - assert len(_upsert_rows_async_calls) == 0 - - # THEN the columns should exist in the entity view - assert "column_string" in entityview.columns - assert "integer_column" in entityview.columns - assert "float_column" in entityview.columns - - # AND the data should be queryable - query_results = query( - f"SELECT * FROM {entityview.id}", synapse_client=self.syn - ) - - # AND the values should match what we set - # Create series with matching names or ignore name attribute in comparison - pd.testing.assert_series_equal( - query_results["column_string"], - pd.Series(test_data["column_string"], name="column_string"), - check_names=True, - check_dtype=False, - ) - pd.testing.assert_series_equal( - query_results["integer_column"], - pd.Series(test_data["integer_column"], name="integer_column"), - check_names=True, - check_dtype=False, - ) - pd.testing.assert_series_equal( - query_results["float_column"], - pd.Series(test_data["float_column"], name="float_column"), - check_names=True, - check_dtype=False, - ) - - # AND the annotations should be updated on the files - for i, file in enumerate(files): - file_copy = File(id=file.id, download_file=False).get( - synapse_client=self.syn - ) - assert file_copy.annotations["column_string"] == [ - test_data["column_string"][i] - ] - - if test_data["integer_column"][i] is not None: - assert file_copy.annotations["integer_column"] == [ - test_data["integer_column"][i] - ] - else: - assert "integer_column" not in file_copy.annotations.keys() - - if test_data["float_column"][i] is not None: - assert file_copy.annotations["float_column"] == [ - test_data["float_column"][i] - ] - else: - assert "float_column" not in file_copy.annotations.keys() - - def test_update_rows_without_id_column(self, project_model: Project) -> None: - """Test that updating rows requires the id column""" - # GIVEN a folder with files and an entity view - folder, _ = self.setup_files_in_folder(project_model, num_files=1) - - entityview = EntityView( - name=str(uuid.uuid4()), - parent_id=project_model.id, - view_type_mask=ViewTypeMask.FILE.value, - scope_ids=[folder.id], - ) - entityview = entityview.store(synapse_client=self.syn) - self.schedule_for_cleanup(entityview.id) - - # WHEN I delete the id column and try to update rows - entityview.delete_column(name="id") - entityview.store(synapse_client=self.syn) - - # THEN it should raise an exception - with pytest.raises(ValueError) as e: - entityview.update_rows( - values={}, - primary_keys=["id"], - synapse_client=self.syn, - wait_for_eventually_consistent_view=True, - ) - - assert ( - "The 'id' column is required to wait for eventually consistent views." - in str(e.value) - ) - - def test_column_modifications(self, project_model: Project) -> None: - """Test renaming and deleting columns in an entity view""" - # GIVEN an entity view with multiple columns - old_column_name = "column_string" - column_to_keep = "column_to_keep" - - entityview = EntityView( - name=str(uuid.uuid4()), - parent_id=project_model.id, - columns=[ - Column(name=old_column_name, column_type=ColumnType.STRING), - Column(name=column_to_keep, column_type=ColumnType.STRING), - ], - view_type_mask=ViewTypeMask.FILE, - include_default_columns=False, - ) - - entityview = entityview.store(synapse_client=self.syn) - self.schedule_for_cleanup(entityview.id) - - # WHEN I rename a column - new_column_name = "new_column_string" - entityview.columns[old_column_name].name = new_column_name - entityview.store(synapse_client=self.syn) - - # THEN the column should be renamed - # Both in the local instance - assert new_column_name in entityview.columns - assert old_column_name not in entityview.columns - - # And on the server - retrieved_view = EntityView(id=entityview.id).get( - synapse_client=self.syn, include_columns=True - ) - assert new_column_name in retrieved_view.columns - assert old_column_name not in retrieved_view.columns - - # WHEN I delete a column - entityview.delete_column(name=new_column_name) - entityview.store(synapse_client=self.syn) - - # THEN the column should be deleted - # Both in the local instance - assert new_column_name not in entityview.columns - assert column_to_keep in entityview.columns - - # And on the server - retrieved_view = EntityView(id=entityview.id).get( - synapse_client=self.syn, include_columns=True - ) - assert new_column_name not in retrieved_view.columns - assert column_to_keep in retrieved_view.columns - - def test_query_with_part_mask(self, project_model: Project) -> None: - """Test querying an entity view with different part masks""" - # GIVEN a folder with files - folder, files = self.setup_files_in_folder(project_model, num_files=2) - - # AND an entity view with the folder in scope - entityview = EntityView( - name=str(uuid.uuid4()), - parent_id=project_model.id, - view_type_mask=ViewTypeMask.FILE.value, - scope_ids=[folder.id], - ) - entityview = entityview.store(synapse_client=self.syn) - self.schedule_for_cleanup(entityview.id) - - # WHEN I query with a full part mask - query_results = 0x1 - query_count = 0x2 - sum_file_size_bytes = 0x40 - last_updated_on = 0x80 - full_part_mask = ( - query_results | query_count | sum_file_size_bytes | last_updated_on - ) - - full_results = query_part_mask( - query=f"SELECT * FROM {entityview.id} ORDER BY id ASC", - synapse_client=self.syn, - part_mask=full_part_mask, - ) - - # THEN all parts should be present in the results - assert full_results.count == len(files) - assert full_results.sum_file_sizes is not None - assert full_results.sum_file_sizes.greater_than is not None - assert full_results.sum_file_sizes.sum_file_size_bytes is not None - assert full_results.last_updated_on is not None - assert full_results.result["name"].tolist() == [file.name for file in files] - - # WHEN I query with only the results part mask - results_only = query_part_mask( - query=f"SELECT * FROM {entityview.id} ORDER BY id ASC", - synapse_client=self.syn, - part_mask=query_results, - ) - - # THEN only the results should be present - assert results_only.count is None - assert results_only.sum_file_sizes is None - assert results_only.last_updated_on is None - assert results_only.result["name"].tolist() == [file.name for file in files] - - def test_snapshot_functionality(self, project_model: Project) -> None: - """Test creating snapshots of entity views with different activity configurations""" - # GIVEN a folder with a file - folder, [file] = self.setup_files_in_folder(project_model, num_files=1) - - # AND an entity view with an activity - entityview = EntityView( - name=str(uuid.uuid4()), - parent_id=project_model.id, - description="Test entityview for snapshots", - scope_ids=[folder.id], - view_type_mask=ViewTypeMask.FILE, - activity=Activity( - name="Original activity", - used=[UsedURL(name="Synapse", url="https://synapse.org")], - ), - ) - entityview = entityview.store(synapse_client=self.syn) - self.schedule_for_cleanup(entityview.id) - - # Test different snapshot configurations - snapshot_configs = [ - { - "name": "with_activity_pulled_forward", - "include_activity": True, - "associate_activity_to_new_version": True, - "expect_activity_in_snapshot": True, - "expect_activity_in_new_version": True, - }, - { - "name": "with_activity_not_pulled_forward", - "include_activity": True, - "associate_activity_to_new_version": False, - "expect_activity_in_snapshot": True, - "expect_activity_in_new_version": False, - }, - { - "name": "without_activity", - "include_activity": False, - "associate_activity_to_new_version": False, - "expect_activity_in_snapshot": False, - "expect_activity_in_new_version": False, - }, - ] - - # Test each configuration in succession - for i, config in enumerate(snapshot_configs): - # WHEN I create a snapshot with this configuration - snapshot = entityview.snapshot( - comment=f"Snapshot {i+1}", - label=f"Label {i+1}", - include_activity=config["include_activity"], - associate_activity_to_new_version=config[ - "associate_activity_to_new_version" - ], - synapse_client=self.syn, - ) - - # THEN the snapshot should be created - assert snapshot.results is not None - - # AND the snapshot should have the expected properties - snapshot_version = i + 1 - snapshot_instance = EntityView( - id=entityview.id, version_number=snapshot_version - ).get(synapse_client=self.syn, include_activity=True) - - assert snapshot_instance.version_number == snapshot_version - assert snapshot_instance.version_comment == f"Snapshot {snapshot_version}" - assert snapshot_instance.version_label == f"Label {snapshot_version}" - - # Check activity in snapshot - if config["expect_activity_in_snapshot"]: - assert snapshot_instance.activity is not None - assert snapshot_instance.activity.name == "Original activity" - assert snapshot_instance.activity.used[0].name == "Synapse" - assert snapshot_instance.activity.used[0].url == "https://synapse.org" - else: - assert snapshot_instance.activity is None - - # Check activity in new version - newest_instance = EntityView(id=entityview.id).get( - synapse_client=self.syn, include_activity=True - ) - assert newest_instance.version_number == snapshot_version + 1 - - if config["expect_activity_in_new_version"]: - assert newest_instance.activity is not None - assert newest_instance.activity.name == "Original activity" - else: - assert newest_instance.activity is None - - def test_snapshot_with_no_scope(self, project_model: Project) -> None: - """Test that creating a snapshot of an entity view with no scope raises an error""" - # GIVEN an entity view with no scope - entityview = EntityView( - name=str(uuid.uuid4()), - parent_id=project_model.id, - description="Test entityview with no scope", - view_type_mask=ViewTypeMask.FILE, - ) - entityview = entityview.store(synapse_client=self.syn) - self.schedule_for_cleanup(entityview.id) - - # WHEN I try to create a snapshot - # THEN it should raise an error - with pytest.raises(SynapseHTTPError) as e: - entityview.snapshot( - comment="My snapshot", - label="My snapshot label", - synapse_client=self.syn, - ) - - assert ( - "400 Client Error: You cannot create a version of a view that has no scope." - in str(e.value) - ) diff --git a/tests/integration/synapseclient/models/synchronous/test_evaluation.py b/tests/integration/synapseclient/models/synchronous/test_evaluation.py deleted file mode 100644 index a1c8fdb6f..000000000 --- a/tests/integration/synapseclient/models/synchronous/test_evaluation.py +++ /dev/null @@ -1,642 +0,0 @@ -"""Integration tests for the synapseclient.models.Evaluation class.""" - -import logging -import uuid -from typing import Callable - -import pytest - -from synapseclient import Synapse -from synapseclient.core.exceptions import SynapseHTTPError -from synapseclient.models import Evaluation, Project - - -class TestEvaluationCreation: - @pytest.fixture(autouse=True, scope="function") - def init(self, syn: Synapse, schedule_for_cleanup: Callable[..., None]) -> None: - self.syn = syn - self.schedule_for_cleanup = schedule_for_cleanup - - def test_create_evaluation(self): - # GIVEN a project to work with - project = Project(name=f"test_project_{uuid.uuid4()}").store( - synapse_client=self.syn - ) - self.schedule_for_cleanup(project.id) - - # WHEN I create an evaluation using the dataclass method - evaluation = Evaluation( - name=f"test_evaluation_{uuid.uuid4()}", - description="A test evaluation for testing purposes", - content_source=project.id, - submission_instructions_message="Please submit your results in CSV format", - submission_receipt_message="Thank you for your submission!", - ) - created_evaluation = evaluation.store(synapse_client=self.syn) - self.schedule_for_cleanup(created_evaluation.id) - - # THEN the evaluation should be created - assert created_evaluation.id is not None - assert created_evaluation.etag is not None # Check that etag is set - assert created_evaluation.name == evaluation.name - assert ( - created_evaluation.description == "A test evaluation for testing purposes" - ) - assert created_evaluation.content_source == project.id - assert created_evaluation.owner_id is not None # Check that owner_id is set - assert created_evaluation.created_on is not None # Check that created_on is set - - -class TestGetEvaluation: - @pytest.fixture(autouse=True, scope="function") - def init(self, syn: Synapse, schedule_for_cleanup: Callable[..., None]) -> None: - self.syn = syn - self.schedule_for_cleanup = schedule_for_cleanup - - @pytest.fixture(scope="function") - def test_project( - self, syn: Synapse, schedule_for_cleanup: Callable[..., None] - ) -> Project: - """Create a test project for evaluation tests.""" - project = Project(name=f"test_project_{uuid.uuid4()}").store( - synapse_client=self.syn - ) - schedule_for_cleanup(project.id) - return project - - @pytest.fixture(scope="function") - def test_evaluation( - self, - test_project: Project, - syn: Synapse, - schedule_for_cleanup: Callable[..., None], - ) -> Evaluation: - """Create a test evaluation for get tests.""" - evaluation = Evaluation( - name=f"test_evaluation_{uuid.uuid4()}", - description="A test evaluation for get tests", - content_source=test_project.id, - submission_instructions_message="Please submit your results", - submission_receipt_message="Thank you!", - ) - created_evaluation = evaluation.store(synapse_client=syn) - schedule_for_cleanup(created_evaluation.id) - return created_evaluation - - @pytest.fixture(scope="function") - def multiple_evaluations( - self, - test_project: Project, - syn: Synapse, - schedule_for_cleanup: Callable[..., None], - ) -> list[Evaluation]: - """Create multiple test evaluations for bulk tests.""" - evaluations = [] - for i in range(3): - evaluation = Evaluation( - name=f"test_evaluation_{i}_{uuid.uuid4()}", - description=f"Test evaluation {i}", - content_source=test_project.id, - submission_instructions_message="Please submit your results", - submission_receipt_message="Thank you!", - ) - created_evaluation = evaluation.store(synapse_client=syn) - schedule_for_cleanup(created_evaluation.id) - evaluations.append(created_evaluation) - return evaluations - - def test_get_evaluation_by_id( - self, test_evaluation: Evaluation, test_project: Project - ): - # WHEN I get an evaluation by id using the dataclass method - retrieved_evaluation = Evaluation(id=test_evaluation.id).get( - synapse_client=self.syn - ) - - # THEN the evaluation should be retrieved - assert retrieved_evaluation.id == test_evaluation.id - assert retrieved_evaluation.etag is not None # Check that etag is set - assert retrieved_evaluation.name == test_evaluation.name - assert retrieved_evaluation.description == test_evaluation.description - assert retrieved_evaluation.content_source == test_project.id - assert retrieved_evaluation.owner_id is not None # Check that owner_id is set - assert ( - retrieved_evaluation.created_on is not None - ) # Check that created_on is set - - def test_get_evaluation_by_name( - self, test_evaluation: Evaluation, test_project: Project - ): - # WHEN I get an evaluation by name using the dataclass method - retrieved_evaluation = Evaluation(name=test_evaluation.name).get( - synapse_client=self.syn - ) - - # THEN the evaluation should be retrieved - assert retrieved_evaluation.id == test_evaluation.id - assert retrieved_evaluation.etag is not None # Check that etag is set - assert retrieved_evaluation.name == test_evaluation.name - assert retrieved_evaluation.description == test_evaluation.description - assert retrieved_evaluation.content_source == test_project.id - assert retrieved_evaluation.owner_id is not None # Check that owner_id is set - assert ( - retrieved_evaluation.created_on is not None - ) # Check that created_on is set - - def test_get_all_evaluations( - self, multiple_evaluations: list[Evaluation], limit: int = 1 - ): - # Test 1: Grab evaluations that the user has access to - # WHEN a call is made to get all evaluations - evaluations = Evaluation.get_all_evaluations(synapse_client=self.syn) - - # THEN the evaluations should be retrieved - assert evaluations is not None - assert len(evaluations) >= len(multiple_evaluations) - - # Test 2: Grab evaluations that the user has access to and are active - # WHEN the active_only parameter is True - active_evaluations = Evaluation.get_all_evaluations( - synapse_client=self.syn, active_only=True - ) - - # THEN the active evaluations should be retrieved - assert active_evaluations is not None - - # Test 3: Grab evaluations based on a limit - # WHEN the limit parameter is set - limited_evaluations = Evaluation.get_all_evaluations( - synapse_client=self.syn, limit=limit - ) - - # THEN the evaluations retrieved should match said limit - assert len(limited_evaluations) == limit - - def test_get_available_evaluations(self, multiple_evaluations: list[Evaluation]): - # WHEN a call is made to get available evaluations for a given user - evaluations = Evaluation.get_available_evaluations(synapse_client=self.syn) - - # THEN the evaluations should be retrieved - assert evaluations is not None - assert len(evaluations) >= len(multiple_evaluations) - - def test_get_evaluations_by_project( - self, test_project: Project, multiple_evaluations: list[Evaluation] - ): - # WHEN a call is made to get evaluations by project - evaluations = Evaluation.get_evaluations_by_project( - project_id=test_project.id, synapse_client=self.syn - ) - - # THEN the evaluations should be retrieved - assert evaluations is not None - assert len(evaluations) >= len(multiple_evaluations) - - # AND all returned evaluations belong to the test project - for evaluation in evaluations: - assert evaluation.content_source == test_project.id - - -class TestStoreEvaluation: - @pytest.fixture(autouse=True, scope="function") - def init(self, syn: Synapse, schedule_for_cleanup: Callable[..., None]) -> None: - self.syn = syn - self.schedule_for_cleanup = schedule_for_cleanup - - @pytest.fixture(scope="function") - def test_project( - self, syn: Synapse, schedule_for_cleanup: Callable[..., None] - ) -> Project: - """Create a test project for evaluation tests.""" - project = Project(name=f"test_project_{uuid.uuid4()}").store( - synapse_client=self.syn - ) - schedule_for_cleanup(project.id) - return project - - @pytest.fixture(scope="function") - def test_evaluation( - self, - test_project: Project, - syn: Synapse, - schedule_for_cleanup: Callable[..., None], - ) -> Evaluation: - """Create a test evaluation for update tests.""" - evaluation = Evaluation( - name=f"test_evaluation_{uuid.uuid4()}", - description="A test evaluation for update tests", - content_source=test_project.id, - submission_instructions_message="Please submit your results", - submission_receipt_message="Thank you!", - ) - created_evaluation = evaluation.store(synapse_client=syn) - schedule_for_cleanup(created_evaluation.id) - return created_evaluation - - def test_store_evaluation_with_same_name( - self, test_project: Project, test_evaluation: Evaluation - ): - # GIVEN an existing evaluation - existing_name = test_evaluation.name - - # WHEN I try to create a new evaluation with the same name - duplicate_evaluation = Evaluation( - name=existing_name, # Use the same name as the existing evaluation - description="This is a duplicate evaluation name test", - content_source=test_project.id, - submission_instructions_message="Submit your results here", - submission_receipt_message="Thank you for submitting!", - ) - - # THEN it should raise a SynapseHTTPError with an appropriate message about name conflict - with pytest.raises(SynapseHTTPError) as excinfo: - duplicate_evaluation.store(synapse_client=self.syn) - - # AND the error message contains information about the duplicate name - error_message = str(excinfo.value).lower() - assert ( - "already exists with the name" in error_message - ), f"Unexpected error message: {error_message}" - - def test_update_evaluation_name(self, test_evaluation: Evaluation): - # WHEN I update the evaluation name in my evaluation object - new_name = f"updated_evaluation_{uuid.uuid4()}" - test_evaluation.name = new_name - - updated_evaluation = test_evaluation.store(synapse_client=self.syn) - - # THEN the evaluation should be updated - assert updated_evaluation.name == new_name - assert updated_evaluation.id == test_evaluation.id - assert updated_evaluation.description == test_evaluation.description - assert updated_evaluation.etag == test_evaluation.etag - - def test_update_evaluation_description(self, test_evaluation: Evaluation): - # WHEN I update the evaluation description - new_description = f"Updated description {uuid.uuid4()}" - old_etag = test_evaluation.etag - test_evaluation.description = new_description - - updated_evaluation = test_evaluation.store(synapse_client=self.syn) - - # THEN the evaluation should be updated - assert updated_evaluation.description == new_description - assert updated_evaluation.id == test_evaluation.id - assert updated_evaluation.name == test_evaluation.name - - # AND the etag is updated after an update operation - assert updated_evaluation.etag is not None - assert updated_evaluation.etag != old_etag - - def test_update_multiple_fields(self, test_evaluation: Evaluation): - # WHEN I update multiple fields at once - new_name = f"multi_update_{uuid.uuid4()}" - new_description = f"Multi-updated description {uuid.uuid4()}" - new_instructions = "Updated submission instructions" - old_etag = test_evaluation.etag - - test_evaluation.name = new_name - test_evaluation.description = new_description - test_evaluation.submission_instructions_message = new_instructions - - updated_evaluation = test_evaluation.store( - synapse_client=self.syn, - ) - - # THEN all fields should be updated - assert updated_evaluation.name == new_name - assert updated_evaluation.description == new_description - assert updated_evaluation.submission_instructions_message == new_instructions - assert updated_evaluation.id == test_evaluation.id - - # AND the etag is updated after an update operation - assert updated_evaluation.etag is not None - assert updated_evaluation.etag != old_etag - - def test_certain_fields_unchanged_once_retrieved_from_synapse( - self, test_evaluation: Evaluation - ): - # GIVEN an existing evaluation - retrieved_evaluation = Evaluation(id=test_evaluation.id).get( - synapse_client=self.syn - ) - - # WHEN I attempt to change immutable fields - original_id = retrieved_evaluation.id - original_content_source = retrieved_evaluation.content_source - - retrieved_evaluation.id = "syn999999999" # Attempt to change ID - retrieved_evaluation.content_source = ( - "syn888888888" # Attempt to change content_source - ) - - updated_evaluation = retrieved_evaluation.store(synapse_client=self.syn) - - # THEN those fields should remain unchanged after the store operation - assert updated_evaluation.id == original_id - assert updated_evaluation.content_source == original_content_source - - def test_store_with_nonexistent_id(self, test_project: Project): - # GIVEN an evaluation with a non-existent ID that's never been stored - unique_name = f"test_evaluation_{uuid.uuid4()}" - evaluation = Evaluation( - id="syn999999999", - name=unique_name, - description="Test description", - content_source=test_project.id, - submission_instructions_message="Instructions", - submission_receipt_message="Receipt", - ) - - # WHEN I store the evaluation - # THEN it should succeed by ignoring the invalid ID - created_eval = evaluation.store(synapse_client=self.syn) - self.schedule_for_cleanup(created_eval.id) - - # AND other important attribute should not be changed - assert created_eval.name == unique_name - - # GIVEN an evaluation that was retrieved from Synapse - # AND modified with a non-existent ID - retrieved_eval = Evaluation(id=created_eval.id).get(synapse_client=self.syn) - original_id = retrieved_eval.id - retrieved_eval.id = "syn999999999" - retrieved_eval.name = f"test_evaluation_{uuid.uuid4()}_new_name" - - # WHEN I update the evaluation - # THEN it should succeed and ignore the invalid ID (with warning) - updated_eval = retrieved_eval.store(synapse_client=self.syn) - - # AND the updated evaluation should maintain its original ID - assert updated_eval.id == original_id - assert updated_eval.id != "syn999999999" - assert updated_eval.name == retrieved_eval.name - - def test_store_unchanged_evaluation(self, test_evaluation: Evaluation, monkeypatch): - warning_messages = [] - - def mock_warning(self, msg, *args, **kwargs): - """ - Using the method interception pattern to mock the implementation of logging.Logger.warning - to create a mock logger that captures warning messages. - - When the Evaluation.store method detects no changes, it logs a warning - using logger.warning(). This mock replaces the actual logging function to - capture those warning messages in our warning_messages list instead of sending - them to the logging system. This allows us to assert that the expected warning - was generated without depending on the logging configuration. - """ - warning_messages.append(msg) - - monkeypatch.setattr(logging.Logger, "warning", mock_warning) - - # GIVEN an evaluation that has not been changed - retrieved_evaluation = Evaluation(id=test_evaluation.id).get( - synapse_client=self.syn - ) - - # WHEN trying to store the unchanged evaluation - result = retrieved_evaluation.store(synapse_client=self.syn) - - # THEN it should not be updated and return the same instance - assert result is retrieved_evaluation - - # AND a warning should be logged indicating no changes were detected - warning_text = "has not changed since last 'store' or 'get' event" - - # Check if any captured warning contains our expected text - assert any( - warning_text in msg for msg in warning_messages - ), f"Warning message not found in captured warnings: {warning_messages}" - - -class TestDeleteEvaluation: - @pytest.fixture(autouse=True, scope="function") - def init(self, syn: Synapse, schedule_for_cleanup: Callable[..., None]) -> None: - self.syn = syn - self.schedule_for_cleanup = schedule_for_cleanup - - @pytest.fixture(scope="function") - def test_project( - self, syn: Synapse, schedule_for_cleanup: Callable[..., None] - ) -> Project: - """Create a test project for evaluation tests.""" - project = Project(name=f"test_project_{uuid.uuid4()}").store( - synapse_client=self.syn - ) - schedule_for_cleanup(project.id) - return project - - @pytest.fixture(scope="function") - def test_evaluation( - self, - test_project: Project, - syn: Synapse, - schedule_for_cleanup: Callable[..., None], - ) -> Evaluation: - """Create a test evaluation for delete tests.""" - evaluation = Evaluation( - name=f"test_evaluation_{uuid.uuid4()}", - description="A test evaluation for delete tests", - content_source=test_project.id, - submission_instructions_message="Please submit your results", - submission_receipt_message="Thank you!", - ) - created_evaluation = evaluation.store(synapse_client=syn) - schedule_for_cleanup(created_evaluation.id) - return created_evaluation - - def test_delete_evaluation(self, test_evaluation: Evaluation): - # WHEN I delete the evaluation using the dataclass method - test_evaluation.delete(synapse_client=self.syn) - - # THEN the evaluation should be deleted (attempting to get it should raise an exception) - with pytest.raises(SynapseHTTPError): - Evaluation(id=test_evaluation.id).get(synapse_client=self.syn) - - -class TestEvaluationAccess: - @pytest.fixture(autouse=True, scope="function") - def init(self, syn: Synapse, schedule_for_cleanup: Callable[..., None]) -> None: - self.syn = syn - self.schedule_for_cleanup = schedule_for_cleanup - - @pytest.fixture(scope="function") - def test_project( - self, syn: Synapse, schedule_for_cleanup: Callable[..., None] - ) -> Project: - """Create a test project for evaluation tests.""" - project = Project(name=f"test_project_{uuid.uuid4()}").store( - synapse_client=self.syn - ) - schedule_for_cleanup(project.id) - return project - - @pytest.fixture(scope="function") - def test_evaluation( - self, - test_project: Project, - syn: Synapse, - schedule_for_cleanup: Callable[..., None], - ) -> Evaluation: - """Create a test evaluation for access tests.""" - evaluation = Evaluation( - name=f"test_evaluation_{uuid.uuid4()}", - description="A test evaluation for access tests", - content_source=test_project.id, - submission_instructions_message="Please submit your results", - submission_receipt_message="Thank you!", - ) - created_evaluation = evaluation.store(synapse_client=syn) - schedule_for_cleanup(created_evaluation.id) - return created_evaluation - - def test_get_evaluation_acl(self, test_evaluation: Evaluation): - # GIVEN the current user's ID - user_profile = self.syn.getUserProfile() - current_user_id = int(user_profile.get("ownerId")) - - # WHEN we get the evaluation ACL using the dataclass method - acl = test_evaluation.get_acl(synapse_client=self.syn) - - # THEN the ACL should be retrieved - assert acl is not None - assert "id" in acl - assert "resourceAccess" in acl - - # AND the ACL ID matches the evaluation ID - assert acl["id"] == test_evaluation.id - - # AND one of the principalIds in the resourceAccess key matches the ID of the user currently accessing Synapse - assert "resourceAccess" in acl and len(acl["resourceAccess"]) > 0 - principal_ids = [ - int(access.get("principalId")) for access in acl["resourceAccess"] - ] - assert ( - current_user_id in principal_ids - ), f"Current user {current_user_id} not found in resourceAccess principal IDs: {principal_ids}" - - def test_get_evaluation_permissions(self, test_evaluation: Evaluation): - # WHEN I get evaluation permissions using the dataclass method - permissions = test_evaluation.get_permissions(synapse_client=self.syn) - - # THEN the permissions should be retrieved - assert permissions is not None - - def test_update_acl_with_principal_id(self, test_evaluation: Evaluation): - """Test updating ACL for an evaluation using principal_id and access_type.""" - # GIVEN the current user's ID - user_profile = self.syn.getUserProfile() - current_user_id = int(user_profile.get("ownerId")) - - # WHEN we update the ACL for the current user with specific permissions - updated_acl = test_evaluation.update_acl( - principal_id=current_user_id, - access_type=["READ", "UPDATE", "DELETE", "CHANGE_PERMISSIONS"], - synapse_client=self.syn, - ) - - # THEN the ACL should be updated - assert updated_acl is not None - assert "resourceAccess" in updated_acl - - # AND the user's permissions should be updated - user_access = None - for access in updated_acl["resourceAccess"]: - if int(access.get("principalId")) == current_user_id: - user_access = access - break - - assert ( - user_access is not None - ), f"User {current_user_id} not found in updated ACL" - assert set(user_access["accessType"]) == set( - ["READ", "UPDATE", "DELETE", "CHANGE_PERMISSIONS"] - ) - - def test_update_acl_with_full_dictionary(self, test_evaluation: Evaluation): - """Test updating ACL for an evaluation using a complete ACL dictionary.""" - # GIVEN the current ACL - current_acl = test_evaluation.get_acl(synapse_client=self.syn) - - # AND a modified version of the ACL with a changed permission set - modified_acl = current_acl.copy() - user_profile = self.syn.getUserProfile() - current_user_id = int(user_profile.get("ownerId")) - - # Find the current user in the ACL and update permissions - for access in modified_acl["resourceAccess"]: - if int(access.get("principalId")) == current_user_id: - access["accessType"] = ["READ", "DELETE", "SUBMIT"] - break - - # WHEN we update the ACL with the complete dictionary - updated_acl = test_evaluation.update_acl( - acl=modified_acl, synapse_client=self.syn - ) - - # THEN the ACL should be updated - assert updated_acl is not None - - # AND the user's permissions should be updated - user_access = None - for access in updated_acl["resourceAccess"]: - if int(access.get("principalId")) == current_user_id: - user_access = access - break - - assert ( - user_access is not None - ), f"User {current_user_id} not found in updated ACL" - assert set(user_access["accessType"]) == set(["READ", "DELETE", "SUBMIT"]) - - -class TestEvaluationValidation: - @pytest.fixture(autouse=True, scope="function") - def init(self, syn: Synapse, schedule_for_cleanup: Callable[..., None]) -> None: - self.syn = syn - self.schedule_for_cleanup = schedule_for_cleanup - - def test_create_evaluation_missing_required_fields(self): - # WHEN I try to create an evaluation with missing required fields - evaluation = Evaluation(name="test_evaluation") - - # THEN it should raise a ValueError - with pytest.raises(ValueError, match="missing the 'description' attribute"): - evaluation.store(synapse_client=self.syn) - - def test_get_evaluation_missing_id_and_name(self): - # WHEN I try to get an evaluation without id or name - evaluation = Evaluation() - - # THEN it should raise a ValueError - with pytest.raises( - ValueError, match="Either id or name must be set to get an evaluation" - ): - evaluation.get(synapse_client=self.syn) - - def test_delete_evaluation_missing_id(self): - # WHEN I try to delete an evaluation without an id - evaluation = Evaluation(name="test_evaluation") - - # THEN it should raise a ValueError - with pytest.raises(ValueError, match="id must be set to delete an evaluation"): - evaluation.delete(synapse_client=self.syn) - - def test_get_acl_missing_id(self): - # WHEN I try to get ACL for an evaluation without an id - evaluation = Evaluation(name="test_evaluation") - - # THEN it should raise a ValueError - with pytest.raises(ValueError, match="id must be set to get evaluation ACL"): - evaluation.get_acl(synapse_client=self.syn) - - def test_get_permissions_missing_id(self): - # WHEN I try to get permissions for an evaluation without an id - evaluation = Evaluation(name="test_evaluation") - - # THEN it should raise a ValueError - with pytest.raises( - ValueError, match="id must be set to get evaluation permissions" - ): - evaluation.get_permissions(synapse_client=self.syn) diff --git a/tests/integration/synapseclient/models/synchronous/test_file.py b/tests/integration/synapseclient/models/synchronous/test_file.py deleted file mode 100644 index 31e7769a7..000000000 --- a/tests/integration/synapseclient/models/synchronous/test_file.py +++ /dev/null @@ -1,1783 +0,0 @@ -"""Integration tests for the synapseclient.models.File class.""" - -import os -import uuid -from typing import Callable -from unittest.mock import patch - -import pytest - -from synapseclient import Synapse -from synapseclient.core import utils -from synapseclient.core.exceptions import SynapseHTTPError, SynapseMd5MismatchError -from synapseclient.models import Activity, File, Folder, Project, UsedEntity, UsedURL - -DESCRIPTION = "This is an example file." -CONTENT_TYPE = "text/plain" -VERSION_COMMENT = "My version comment" -CONTENT_TYPE_JSON = "text/json" -BOGUS_URL = "https://www.synapse.org/" -BOGUS_MD5 = "1234567890" - - -class TestFileStore: - """Tests for the synapseclient.models.File.store method.""" - - @pytest.fixture(autouse=True, scope="function") - def init(self, syn: Synapse, schedule_for_cleanup: Callable[..., None]) -> None: - self.syn = syn - self.schedule_for_cleanup = schedule_for_cleanup - - @pytest.fixture(autouse=True, scope="function") - def file(self, schedule_for_cleanup: Callable[..., None]) -> None: - filename = utils.make_bogus_uuid_file() - schedule_for_cleanup(filename) - return File( - path=filename, - description=DESCRIPTION, - content_type=CONTENT_TYPE, - version_comment=VERSION_COMMENT, - version_label=str(uuid.uuid4()), - ) - - def test_store_in_project(self, project_model: Project, file: File) -> None: - # GIVEN a file - file.name = str(uuid.uuid4()) - - # WHEN I store the file - file_copy_object = file.store(parent=project_model, synapse_client=self.syn) - self.schedule_for_cleanup(file.id) - - # THEN I expect the file to be stored - assert file.id is not None - assert file_copy_object.id is not None - assert file_copy_object == file - assert file.parent_id == project_model.id - assert file.content_type == CONTENT_TYPE - assert file.version_comment == VERSION_COMMENT - assert file.version_label is not None - assert file.version_number == 1 - assert file.created_by is not None - assert file.created_on is not None - assert file.modified_by is not None - assert file.modified_on is not None - assert file.data_file_handle_id is not None - assert file.file_handle is not None - assert file.file_handle.id is not None - assert file.file_handle.etag is not None - assert file.file_handle.created_by is not None - assert file.file_handle.created_on is not None - assert file.file_handle.modified_on is not None - assert file.file_handle.concrete_type is not None - assert file.file_handle.content_type is not None - assert file.file_handle.content_md5 is not None - assert file.file_handle.file_name is not None - assert file.file_handle.storage_location_id is not None - assert file.file_handle.content_size is not None - assert file.file_handle.status is not None - assert file.file_handle.bucket_name is not None - assert file.file_handle.key is not None - assert file.file_handle.external_url is None - - def test_activity_store_then_delete( - self, project_model: Project, file: File - ) -> None: - # GIVEN a file - file.name = str(uuid.uuid4()) - - # AND the file has an activity - activity = Activity( - name="some_name", - description="some_description", - used=[ - UsedURL(name="example", url=BOGUS_URL), - ], - ) - file.activity = activity - - # WHEN I store the file - file.store(parent=project_model, synapse_client=self.syn) - self.schedule_for_cleanup(file.id) - - # THEN I expect the file to be stored - assert file.id is not None - assert file.version_number == 1 - assert file.activity is not None - assert file.activity.id is not None - assert file.activity.etag is not None - assert file.activity.created_on is not None - assert file.activity.modified_on is not None - assert file.activity.created_by is not None - assert file.activity.modified_by is not None - assert file.activity.used[0].url == BOGUS_URL - assert file.activity.used[0].name == "example" - - # WHEN I remove the activity from the file - file.activity.disassociate_from_entity(parent=file, synapse_client=self.syn) - - # AND I store the file again - file.store(synapse_client=self.syn) - - # THEN I expect the activity to be removed - file_copy = File(id=file.id, download_file=False).get( - include_activity=True, synapse_client=self.syn - ) - assert file_copy.activity is None - assert file.activity is None - assert file.version_number == 1 - - def test_store_in_folder(self, project_model: Project, file: File) -> None: - # GIVEN a file - file.name = str(uuid.uuid4()) - - # AND a folder to store the file in - folder = Folder(name=str(uuid.uuid4()), parent_id=project_model.id).store( - synapse_client=self.syn - ) - self.schedule_for_cleanup(folder.id) - - # WHEN I store the file - file_copy_object = file.store(parent=folder, synapse_client=self.syn) - self.schedule_for_cleanup(file.id) - - # THEN I expect the file to be stored - assert file.id is not None - assert file_copy_object.id is not None - assert file_copy_object == file - assert file.parent_id == folder.id - assert file.content_type == CONTENT_TYPE - assert file.version_comment == VERSION_COMMENT - assert file.version_label is not None - assert file.version_number == 1 - assert file.created_by is not None - assert file.created_on is not None - assert file.modified_by is not None - assert file.modified_on is not None - assert file.data_file_handle_id is not None - assert file.file_handle is not None - assert file.file_handle.id is not None - assert file.file_handle.etag is not None - assert file.file_handle.created_by is not None - assert file.file_handle.created_on is not None - assert file.file_handle.modified_on is not None - assert file.file_handle.concrete_type is not None - assert file.file_handle.content_type is not None - assert file.file_handle.content_md5 is not None - assert file.file_handle.file_name is not None - assert file.file_handle.storage_location_id is not None - assert file.file_handle.content_size is not None - assert file.file_handle.status is not None - assert file.file_handle.bucket_name is not None - assert file.file_handle.key is not None - assert file.file_handle.external_url is None - - def test_store_multiple_files(self, project_model: Project) -> None: - # GIVEN a file - filename = utils.make_bogus_uuid_file() - self.schedule_for_cleanup(filename) - file_1 = File( - path=filename, - description=DESCRIPTION, - content_type=CONTENT_TYPE, - version_comment=VERSION_COMMENT, - version_label=str(uuid.uuid4()), - ) - - # AND a second file - filename = utils.make_bogus_uuid_file() - self.schedule_for_cleanup(filename) - file_2 = File( - path=filename, - description=DESCRIPTION, - content_type=CONTENT_TYPE, - version_comment=VERSION_COMMENT, - version_label=str(uuid.uuid4()), - ) - - # WHEN I store both the file - files = [ - file_1.store(parent=project_model, synapse_client=self.syn), - file_2.store(parent=project_model, synapse_client=self.syn), - ] - for file in files: - self.schedule_for_cleanup(file.id) - - # THEN I expect the files to be stored - assert file.id is not None - assert file == file_1 or file == file_2 - assert file.parent_id == project_model.id - assert file.content_type == CONTENT_TYPE - assert file.version_comment == VERSION_COMMENT - assert file.version_label is not None - assert file.version_number == 1 - assert file.created_by is not None - assert file.created_on is not None - assert file.modified_by is not None - assert file.modified_on is not None - assert file.data_file_handle_id is not None - assert file.file_handle is not None - assert file.file_handle.id is not None - assert file.file_handle.etag is not None - assert file.file_handle.created_by is not None - assert file.file_handle.created_on is not None - assert file.file_handle.modified_on is not None - assert file.file_handle.concrete_type is not None - assert file.file_handle.content_type is not None - assert file.file_handle.content_md5 is not None - assert file.file_handle.file_name is not None - assert file.file_handle.storage_location_id is not None - assert file.file_handle.content_size is not None - assert file.file_handle.status is not None - assert file.file_handle.bucket_name is not None - assert file.file_handle.key is not None - assert file.file_handle.external_url is None - - def test_store_change_filename(self, project_model: Project, file: File) -> None: - # GIVEN a file - file.name = str(uuid.uuid4()) - file.parent_id = project_model.id - - # WHEN I store the file - file = file.store(synapse_client=self.syn) - self.schedule_for_cleanup(file.id) - before_etag = file.etag - - # AND I change the filename - changed_file_name = str(uuid.uuid4()) - file.name = changed_file_name - - # AND I store the file again - file = file.store(synapse_client=self.syn) - - # THEN I expect the file to be changed - assert file.name == changed_file_name - assert before_etag is not None - assert file.etag is not None - assert before_etag != file.etag - - def test_store_move_file(self, project_model: Project, file: File) -> None: - # GIVEN a file - file.name = str(uuid.uuid4()) - file.parent_id = project_model.id - - # AND a folder to store the file in - folder = Folder(name=str(uuid.uuid4()), parent_id=project_model.id).store( - synapse_client=self.syn - ) - self.schedule_for_cleanup(folder.id) - - # WHEN I store the file in the project - file = file.store(synapse_client=self.syn) - self.schedule_for_cleanup(file.id) - assert file.parent_id == project_model.id - before_file_id = file.id - - # AND I store the file under a new parent - file = file.store(parent=folder, synapse_client=self.syn) - - # THEN I expect the file to have been moved - assert file.parent_id == folder.id - - # AND the file does not have an updated ID - assert before_file_id == file.id - - def test_store_same_data_file_handle_id(self, project_model: Project) -> None: - # GIVEN a file - filename = utils.make_bogus_uuid_file() - self.schedule_for_cleanup(filename) - file_1 = File( - path=filename, - description=DESCRIPTION, - content_type=CONTENT_TYPE, - version_comment=VERSION_COMMENT, - version_label=str(uuid.uuid4()), - ).store(parent=project_model, synapse_client=self.syn) - - # AND a second file - filename = utils.make_bogus_uuid_file() - self.schedule_for_cleanup(filename) - file_2 = File( - path=filename, - description=DESCRIPTION, - content_type=CONTENT_TYPE, - version_comment=VERSION_COMMENT, - version_label=str(uuid.uuid4()), - ).store(parent=project_model, synapse_client=self.syn) - assert file_1.data_file_handle_id is not None - assert file_2.data_file_handle_id is not None - assert file_1.data_file_handle_id != file_2.data_file_handle_id - file_2_etag = file_2.etag - - # WHEN I store the data_file_handle_id onto the second file - file_2.data_file_handle_id = file_1.data_file_handle_id - file_2.store(synapse_client=self.syn) - - # THEN I expect the file handles to match - assert file_2_etag != file_2.etag - - # The file_handle is eventually consistent & changes when a file preview is - # created. To handle for this I am just confirming the IDs match - assert (file_1.get(synapse_client=self.syn)).file_handle.id == ( - file_2.get(synapse_client=self.syn) - ).file_handle.id - - def test_store_updated_file(self, project_model: Project) -> None: - # GIVEN a file - filename = utils.make_bogus_uuid_file() - self.schedule_for_cleanup(filename) - file = File( - path=filename, - description=DESCRIPTION, - content_type=CONTENT_TYPE, - version_comment=VERSION_COMMENT, - version_label=str(uuid.uuid4()), - ).store(parent=project_model, synapse_client=self.syn) - before_etag = file.etag - before_id = file.id - before_file_handle_id = file.file_handle.id - - # WHEN I update the file - filename = utils.make_bogus_uuid_file() - self.schedule_for_cleanup(filename) - file.path = filename - file.store(synapse_client=self.syn) - - # THEN I expect the file to be updated - assert before_etag is not None - assert file.etag is not None - assert before_etag != file.etag - assert before_id == file.id - assert before_file_handle_id is not None - assert file.file_handle.id is not None - assert before_file_handle_id != file.file_handle.id - - def test_store_and_get_activity(self, project_model: Project, file: File) -> None: - # GIVEN an activity - activity = Activity( - name="some_name", - description="some_description", - used=[ - UsedURL(name="example", url=BOGUS_URL), - UsedEntity(target_id="syn456", target_version_number=1), - ], - executed=[ - UsedURL(name="example", url=BOGUS_URL), - UsedEntity(target_id="syn789", target_version_number=1), - ], - ) - - # AND a file with the activity - file.name = str(uuid.uuid4()) - file.activity = activity - - # WHEN I store the file - file = file.store(parent=project_model, synapse_client=self.syn) - self.schedule_for_cleanup(file.id) - - # AND I get the file with the activity - file_copy = File(id=file.id, download_file=False).get( - include_activity=True, synapse_client=self.syn - ) - - # THEN I expect that the activity is returned - assert file_copy.activity is not None - assert file_copy.activity.name == "some_name" - assert file_copy.activity.description == "some_description" - assert file_copy.activity.used[0].name == "example" - assert file_copy.activity.used[0].url == BOGUS_URL - assert file_copy.activity.used[1].target_id == "syn456" - assert file_copy.activity.used[1].target_version_number == 1 - assert file_copy.activity.executed[0].name == "example" - assert file_copy.activity.executed[0].url == BOGUS_URL - assert file_copy.activity.executed[1].target_id == "syn789" - assert file_copy.activity.executed[1].target_version_number == 1 - - # WHEN I get the file without the activity flag - file_copy_2 = File(id=file.id, download_file=False).get(synapse_client=self.syn) - - # THEN I expect that the activity is not returned - assert file_copy_2.activity is None - - def test_store_annotations(self, project_model: Project, file: File) -> None: - # GIVEN an annotation - annotations_for_my_file = { - "my_single_key_string": "a", - "my_key_string": ["b", "a", "c"], - "my_key_bool": [False, False, False], - "my_key_double": [1.2, 3.4, 5.6], - "my_key_long": [1, 2, 3], - } - - # AND a file with the annotation - file.name = str(uuid.uuid4()) - file.annotations = annotations_for_my_file - - # WHEN I store the file - file = file.store(parent=project_model, synapse_client=self.syn) - self.schedule_for_cleanup(file.id) - - # THEN I expect the file annotations to have been stored - assert file.annotations.keys() == annotations_for_my_file.keys() - assert file.annotations["my_single_key_string"] == ["a"] - assert file.annotations["my_key_string"] == ["b", "a", "c"] - assert file.annotations["my_key_bool"] == [False, False, False] - assert file.annotations["my_key_double"] == [1.2, 3.4, 5.6] - assert file.annotations["my_key_long"] == [1, 2, 3] - - # WHEN I update the annotations and store the file again - file.annotations["my_key_string"] = ["new", "values", "here"] - file.store(synapse_client=self.syn) - - # THEN I expect the file annotations to have been updated - assert file.annotations.keys() == annotations_for_my_file.keys() - assert file.annotations["my_single_key_string"] == ["a"] - assert file.annotations["my_key_string"] == ["new", "values", "here"] - assert file.annotations["my_key_bool"] == [False, False, False] - assert file.annotations["my_key_double"] == [1.2, 3.4, 5.6] - assert file.annotations["my_key_long"] == [1, 2, 3] - - def test_setting_annotations_directly( - self, project_model: Project, file: File - ) -> None: - # GIVEN a file with the annotation - file.name = str(uuid.uuid4()) - file.annotations["my_single_key_string"] = "a" - file.annotations["my_key_string"] = ["b", "a", "c"] - file.annotations["my_key_bool"] = [False, False, False] - file.annotations["my_key_double"] = [1.2, 3.4, 5.6] - file.annotations["my_key_long"] = [1, 2, 3] - - # WHEN I store the file - file = file.store(parent=project_model, synapse_client=self.syn) - self.schedule_for_cleanup(file.id) - - # THEN I expect the file annotations to have been stored - assert len(file.annotations.keys()) == 5 - assert file.annotations["my_single_key_string"] == ["a"] - assert file.annotations["my_key_string"] == ["b", "a", "c"] - assert file.annotations["my_key_bool"] == [False, False, False] - assert file.annotations["my_key_double"] == [1.2, 3.4, 5.6] - assert file.annotations["my_key_long"] == [1, 2, 3] - - # WHEN I update the annotations and store the file again - file.annotations["my_key_string"] = ["new", "values", "here"] - file.store(synapse_client=self.syn) - - # THEN I expect the file annotations to have been updated - assert len(file.annotations.keys()) == 5 - assert file.annotations["my_single_key_string"] == ["a"] - assert file.annotations["my_key_string"] == ["new", "values", "here"] - assert file.annotations["my_key_bool"] == [False, False, False] - assert file.annotations["my_key_double"] == [1.2, 3.4, 5.6] - assert file.annotations["my_key_long"] == [1, 2, 3] - - def test_removing_annotations_to_none( - self, project_model: Project, file: File - ) -> None: - # GIVEN an annotation - annotations_for_my_file = { - "my_single_key_string": "a", - "my_key_string": ["b", "a", "c"], - "my_key_bool": [False, False, False], - "my_key_double": [1.2, 3.4, 5.6], - "my_key_long": [1, 2, 3], - } - - # AND a file with the annotation - file.name = str(uuid.uuid4()) - file.annotations = annotations_for_my_file - - # WHEN I store the file - file = file.store(parent=project_model, synapse_client=self.syn) - self.schedule_for_cleanup(file.id) - - # THEN I expect the file annotations to have been stored - assert file.annotations.keys() == annotations_for_my_file.keys() - assert file.annotations["my_single_key_string"] == ["a"] - assert file.annotations["my_key_string"] == ["b", "a", "c"] - assert file.annotations["my_key_bool"] == [False, False, False] - assert file.annotations["my_key_double"] == [1.2, 3.4, 5.6] - assert file.annotations["my_key_long"] == [1, 2, 3] - - # WHEN I update the annotations to None - file.annotations = None - file.store(synapse_client=self.syn) - - # THEN I expect the file annotations to have been removed - assert not file.annotations and isinstance(file.annotations, dict) - - # AND retrieving the file gives an empty dict for the annoations - file_copy = File(id=file.id, download_file=False).get(synapse_client=self.syn) - assert not file_copy.annotations and isinstance(file_copy.annotations, dict) - - def test_removing_annotations_to_empty_dict( - self, project_model: Project, file: File - ) -> None: - # GIVEN an annotation - annotations_for_my_file = { - "my_single_key_string": "a", - "my_key_string": ["b", "a", "c"], - "my_key_bool": [False, False, False], - "my_key_double": [1.2, 3.4, 5.6], - "my_key_long": [1, 2, 3], - } - - # AND a file with the annotation - file.name = str(uuid.uuid4()) - file.annotations = annotations_for_my_file - - # WHEN I store the file - file = file.store(parent=project_model, synapse_client=self.syn) - self.schedule_for_cleanup(file.id) - - # THEN I expect the file annotations to have been stored - assert file.annotations.keys() == annotations_for_my_file.keys() - assert file.annotations["my_single_key_string"] == ["a"] - assert file.annotations["my_key_string"] == ["b", "a", "c"] - assert file.annotations["my_key_bool"] == [False, False, False] - assert file.annotations["my_key_double"] == [1.2, 3.4, 5.6] - assert file.annotations["my_key_long"] == [1, 2, 3] - - # WHEN I update the annotations to an empty dict - file.annotations = {} - file.store(synapse_client=self.syn) - - # THEN I expect the file annotations to have been removed - assert not file.annotations and isinstance(file.annotations, dict) - - # AND retrieving the file gives an empty dict for the annoations - file_copy = File(id=file.id, download_file=False).get(synapse_client=self.syn) - assert not file_copy.annotations and isinstance(file_copy.annotations, dict) - - def test_store_without_upload(self, project_model: Project, file: File) -> None: - # GIVEN a file - file.name = str(uuid.uuid4()) - - # AND the file is not to be uploaded - file.synapse_store = False - - # WHEN I store the file - file = file.store(parent=project_model, synapse_client=self.syn) - self.schedule_for_cleanup(file.id) - - # THEN I expect the file to be stored as an external file - assert file.id is not None - assert file.parent_id == project_model.id - assert file.content_type == CONTENT_TYPE - assert file.version_comment == VERSION_COMMENT - assert file.version_label is not None - assert file.version_number == 1 - assert file.created_by is not None - assert file.created_on is not None - assert file.modified_by is not None - assert file.modified_on is not None - assert file.content_size is not None - assert file.content_type == CONTENT_TYPE - assert file.data_file_handle_id is not None - assert file.file_handle is not None - assert file.file_handle.id is not None - assert file.file_handle.etag is not None - assert file.file_handle.created_by is not None - assert file.file_handle.created_on is not None - assert file.file_handle.modified_on is not None - assert ( - file.file_handle.concrete_type - == "org.sagebionetworks.repo.model.file.ExternalFileHandle" - ) - assert file.file_handle.content_type == CONTENT_TYPE - assert file.file_handle.content_md5 is not None - assert file.file_handle.file_name is not None - assert file.file_handle.content_size is not None - assert file.file_handle.status is not None - assert file.file_handle.bucket_name is None - assert file.file_handle.key is None - - def test_store_without_upload_non_matching_md5( - self, project_model: Project, file: File - ) -> None: - # GIVEN a file - file.name = str(uuid.uuid4()) - - # AND the file is not to be uploaded - file.synapse_store = False - - # AND the file has a content md5 - file.content_md5 = BOGUS_MD5 - - # WHEN I store the file - with pytest.raises(SynapseMd5MismatchError) as e: - file.store(parent=project_model, synapse_client=self.syn) - - assert ( - f"The specified md5 [{BOGUS_MD5}] does not match the calculated md5 " - f"[{utils.md5_for_file_hex(file.path)}] for local file" in str(e.value) - ) - - def test_store_without_upload_non_matching_size( - self, project_model: Project, file: File - ) -> None: - # GIVEN a file - file.name = str(uuid.uuid4()) - - # AND the file is not to be uploaded - file.synapse_store = False - - # AND the file has a content size - file.content_size = 123 - - # WHEN I store the file - file = file.store(parent=project_model, synapse_client=self.syn) - self.schedule_for_cleanup(file.id) - - # THEN I expect the file to be stored as an external file - assert file.id is not None - assert file.parent_id == project_model.id - assert file.content_type == CONTENT_TYPE - assert file.version_comment == VERSION_COMMENT - assert file.version_label is not None - assert file.version_number == 1 - assert file.created_by is not None - assert file.created_on is not None - assert file.modified_by is not None - assert file.modified_on is not None - assert file.content_size != 123 - assert file.content_type == CONTENT_TYPE - assert file.data_file_handle_id is not None - assert file.file_handle is not None - assert file.file_handle.id is not None - assert file.file_handle.etag is not None - assert file.file_handle.created_by is not None - assert file.file_handle.created_on is not None - assert file.file_handle.modified_on is not None - assert ( - file.file_handle.concrete_type - == "org.sagebionetworks.repo.model.file.ExternalFileHandle" - ) - assert file.file_handle.content_type == CONTENT_TYPE - assert file.file_handle.content_md5 is not None - assert file.file_handle.file_name is not None - assert file.file_handle.content_size != 123 - assert file.file_handle.status is not None - assert file.file_handle.bucket_name is None - assert file.file_handle.key is None - - def test_store_as_external_url(self, project_model: Project, file: File) -> None: - # GIVEN a file - file.path = None - file.name = str(uuid.uuid4()) - - # AND the file is not to be uploaded - file.synapse_store = False - - # AND the file is an external URL - file.external_url = BOGUS_URL - - # WHEN I store the file - file = file.store(parent=project_model, synapse_client=self.syn) - self.schedule_for_cleanup(file.id) - - # THEN I expect the file to be stored as an external file - assert file.id is not None - assert file.parent_id == project_model.id - assert file.content_type == CONTENT_TYPE - assert file.version_comment == VERSION_COMMENT - assert file.version_label is not None - assert file.version_number == 1 - assert file.created_by is not None - assert file.created_on is not None - assert file.modified_by is not None - assert file.modified_on is not None - assert file.content_size is None - assert file.content_type == CONTENT_TYPE - assert file.external_url == BOGUS_URL - assert file.data_file_handle_id is not None - assert file.file_handle is not None - assert file.file_handle.id is not None - assert file.file_handle.etag is not None - assert file.file_handle.created_by is not None - assert file.file_handle.created_on is not None - assert file.file_handle.modified_on is not None - assert ( - file.file_handle.concrete_type - == "org.sagebionetworks.repo.model.file.ExternalFileHandle" - ) - assert file.file_handle.content_type == CONTENT_TYPE - assert file.file_handle.content_md5 is None - assert file.file_handle.file_name is not None - assert file.file_handle.content_size is None - assert file.file_handle.status is not None - assert file.file_handle.bucket_name is None - assert file.file_handle.key is None - assert file.file_handle.external_url == BOGUS_URL - - def test_store_as_external_url_with_content_size( - self, project_model: Project, file: File - ) -> None: - # GIVEN a file - file.path = None - file.name = str(uuid.uuid4()) - - # AND the file is not to be uploaded - file.synapse_store = False - - # AND the file is an external URL - file.external_url = BOGUS_URL - - # AND the file has a content size - file.content_size = 123 - - # WHEN I store the file - file = file.store(parent=project_model, synapse_client=self.syn) - self.schedule_for_cleanup(file.id) - - # THEN I expect the file to be stored as an external file - assert file.id is not None - assert file.parent_id == project_model.id - assert file.content_type == CONTENT_TYPE - assert file.version_comment == VERSION_COMMENT - assert file.version_label is not None - assert file.version_number == 1 - assert file.created_by is not None - assert file.created_on is not None - assert file.modified_by is not None - assert file.modified_on is not None - assert file.content_size == 123 - assert file.content_type == CONTENT_TYPE - assert file.external_url == BOGUS_URL - assert file.data_file_handle_id is not None - assert file.file_handle is not None - assert file.file_handle.id is not None - assert file.file_handle.etag is not None - assert file.file_handle.created_by is not None - assert file.file_handle.created_on is not None - assert file.file_handle.modified_on is not None - assert ( - file.file_handle.concrete_type - == "org.sagebionetworks.repo.model.file.ExternalFileHandle" - ) - assert file.file_handle.content_type == CONTENT_TYPE - assert file.file_handle.content_md5 is None - assert file.file_handle.file_name is not None - assert file.file_handle.content_size == 123 - assert file.file_handle.status is not None - assert file.file_handle.bucket_name is None - assert file.file_handle.key is None - assert file.file_handle.external_url == BOGUS_URL - - def test_store_as_external_url_with_content_size_and_md5( - self, project_model: Project, file: File - ) -> None: - # GIVEN a file - file.path = None - file.name = str(uuid.uuid4()) - - # AND the file is not to be uploaded - file.synapse_store = False - - # AND the file is an external URL - file.external_url = BOGUS_URL - - # AND the file has a content size - file.content_size = 123 - - # AND the file has a content md5 - file.content_md5 = BOGUS_MD5 - - # WHEN I store the file - file = file.store(parent=project_model, synapse_client=self.syn) - self.schedule_for_cleanup(file.id) - - # THEN I expect the file to be stored as an external file - assert file.id is not None - assert file.parent_id == project_model.id - assert file.content_type == CONTENT_TYPE - assert file.version_comment == VERSION_COMMENT - assert file.version_label is not None - assert file.version_number == 1 - assert file.created_by is not None - assert file.created_on is not None - assert file.modified_by is not None - assert file.modified_on is not None - assert file.content_size == 123 - assert file.content_type == CONTENT_TYPE - assert file.external_url == BOGUS_URL - assert file.content_md5 is None - assert file.data_file_handle_id is not None - assert file.file_handle is not None - assert file.file_handle.id is not None - assert file.file_handle.etag is not None - assert file.file_handle.created_by is not None - assert file.file_handle.created_on is not None - assert file.file_handle.modified_on is not None - assert ( - file.file_handle.concrete_type - == "org.sagebionetworks.repo.model.file.ExternalFileHandle" - ) - assert file.file_handle.content_type == CONTENT_TYPE - assert file.file_handle.file_name is not None - assert file.file_handle.content_size == 123 - assert file.file_handle.status is not None - assert file.file_handle.bucket_name is None - assert file.file_handle.key is None - assert file.file_handle.external_url == BOGUS_URL - assert file.file_handle.content_md5 == BOGUS_MD5 - - def test_store_external_url_then_update( - self, project_model: Project, file: File - ) -> None: - # GIVEN a file with an external URL - file.path = None - file.name = str(uuid.uuid4()) - file.synapse_store = False - file.external_url = BOGUS_URL - - # WHEN I store the file - file = file.store(parent=project_model, synapse_client=self.syn) - self.schedule_for_cleanup(file.id) - - # THEN I expect the file to be stored with the external URL - assert file.id is not None - assert file.external_url == BOGUS_URL - assert file.version_number == 1 - assert ( - file.file_handle.concrete_type - == "org.sagebionetworks.repo.model.file.ExternalFileHandle" - ) - assert file.file_handle.external_url == BOGUS_URL - original_file_handle_id = file.file_handle.id - - # WHEN I update the external URL - new_external_url = "https://www.example.com/updated" - file.external_url = new_external_url - file = file.store(synapse_client=self.syn) - - # THEN I expect the file to be updated with the new external URL - assert file.external_url == new_external_url - assert file.version_number == 2 - assert file.file_handle.external_url == new_external_url - assert file.file_handle.id != original_file_handle_id - - def test_store_external_url_without_synapse_store_false( - self, project_model: Project, file: File - ) -> None: - # GIVEN a file with an external URL but synapse_store not explicitly set to False - file.path = None - file.name = str(uuid.uuid4()) - file.external_url = BOGUS_URL - - # WHEN I store the file - file = file.store(parent=project_model, synapse_client=self.syn) - self.schedule_for_cleanup(file.id) - - # THEN I expect the file to be stored with the external URL - # and synapse_store should be implicitly set to False - assert file.id is not None - assert file.external_url == BOGUS_URL - assert file.synapse_store is False - assert ( - file.file_handle.concrete_type - == "org.sagebionetworks.repo.model.file.ExternalFileHandle" - ) - assert file.file_handle.external_url == BOGUS_URL - assert file.file_handle.bucket_name is None - assert file.file_handle.key is None - - def test_store_conflict_with_existing_object( - self, project_model: Project, file: File - ) -> None: - # GIVEN a file - file.name = str(uuid.uuid4()) - - # WHEN I store the file - file_copy_object = file.store(parent=project_model, synapse_client=self.syn) - self.schedule_for_cleanup(file.id) - - # THEN I expect the file to be stored - assert file.id is not None - assert file_copy_object.id is not None - assert file_copy_object == file - assert file.parent_id == project_model.id - assert file.content_type == CONTENT_TYPE - assert file.version_comment == VERSION_COMMENT - assert file.version_label is not None - assert file.version_number == 1 - assert file.created_by is not None - assert file.created_on is not None - assert file.modified_by is not None - assert file.modified_on is not None - assert file.data_file_handle_id is not None - assert file.file_handle is not None - assert file.file_handle.id is not None - assert file.file_handle.etag is not None - assert file.file_handle.created_by is not None - assert file.file_handle.created_on is not None - assert file.file_handle.modified_on is not None - assert file.file_handle.concrete_type is not None - assert file.file_handle.content_type is not None - assert file.file_handle.content_md5 is not None - assert file.file_handle.file_name is not None - assert file.file_handle.storage_location_id is not None - assert file.file_handle.content_size is not None - assert file.file_handle.status is not None - assert file.file_handle.bucket_name is not None - assert file.file_handle.key is not None - assert file.file_handle.external_url is None - - # WHEN I store a file with the same properties - filename = utils.make_bogus_uuid_file() - self.schedule_for_cleanup(filename) - new_file = File( - path=filename, - parent_id=project_model.id, - name=file.name, - create_or_update=False, - ) - - # THEN I expect a SynapseHTTPError to be raised - with pytest.raises(SynapseHTTPError) as e: - new_file.store(synapse_client=self.syn) - - assert ( - f"409 Client Error: An entity with the name: {file.name} already exists with a parentId: {project_model.id}" - in str(e.value) - ) - - def test_store_force_version_no_change( - self, project_model: Project, file: File - ) -> None: - # GIVEN a file - file.name = str(uuid.uuid4()) - - # WHEN I store the file - file.store(parent=project_model, synapse_client=self.syn) - self.schedule_for_cleanup(file.id) - - # THEN I expect the file to be stored - assert file.id is not None - assert file.version_number == 1 - - # WHEN I store the file again with force_version=False - file.force_version = False - file.store(synapse_client=self.syn) - - # THEN the version should not be updated - assert file.version_number == 1 - - # WHEN I store the file again with force_version=True and no change was made - file.force_version = True - file.store(synapse_client=self.syn) - - # THEN the version should not be updated - assert file.version_number == 1 - - def test_store_force_version_with_change( - self, project_model: Project, file: File - ) -> None: - # GIVEN a file - file.name = str(uuid.uuid4()) - - # WHEN I store the file - file.store(parent=project_model, synapse_client=self.syn) - self.schedule_for_cleanup(file.id) - - # THEN I expect the file to be stored - assert file.id is not None - assert file.version_number == 1 - - # WHEN I store the file again with force_version=False - file.force_version = False - file.description = "aaaaaaaaaaaaaaaa" - file.store(synapse_client=self.syn) - - # THEN the version should not be updated - assert file.version_number == 1 - - # WHEN I store the file again with force_version=True and I update a field - file.force_version = True - file.description = "new description" - file.store(synapse_client=self.syn) - - # THEN the version should be updated - assert file.version_number == 2 - - def test_store_is_restricted(self, project_model: Project, file: File) -> None: - """Tests that setting is_restricted is calling the correct Synapse method. We are - not testing the actual behavior of the method, only that it is called with the - correct arguments. We do not want to actually restrict the file in Synapse as it - would send an email to our ACT team.""" - # GIVEN a file - file.name = str(uuid.uuid4()) - - # AND the file is restricted - file.is_restricted = True - - with patch( - "synapseclient.models.services.storable_entity.create_access_requirements_if_none" - ) as intercepted: - # WHEN I store the file - file.store(parent=project_model, synapse_client=self.syn) - self.schedule_for_cleanup(file.id) - - # THEN I expect the file to be restricted - assert intercepted.called - - def test_store_and_get_with_activity( - self, project_model: Project, file: File - ) -> None: - # GIVEN a file - file.name = str(uuid.uuid4()) - - # AND an activity - activity = Activity( - name="some_name", - description="some_description", - used=[ - UsedURL(name="example", url=BOGUS_URL), - UsedEntity(target_id="syn456", target_version_number=1), - ], - executed=[ - UsedURL(name="example", url=BOGUS_URL), - UsedEntity(target_id="syn789", target_version_number=1), - ], - ) - file.activity = activity - - # WHEN I store the file - file_copy_object = file.store(parent=project_model, synapse_client=self.syn) - self.schedule_for_cleanup(file.id) - - # THEN I expect the file to be stored - assert file.id is not None - assert file_copy_object.id is not None - assert file_copy_object == file - assert file.parent_id == project_model.id - assert file.content_type == CONTENT_TYPE - assert file.version_comment == VERSION_COMMENT - assert file.version_label is not None - assert file.version_number == 1 - assert file.created_by is not None - assert file.created_on is not None - assert file.modified_by is not None - assert file.modified_on is not None - assert file.data_file_handle_id is not None - assert file.file_handle is not None - assert file.file_handle.id is not None - assert file.file_handle.etag is not None - assert file.file_handle.created_by is not None - assert file.file_handle.created_on is not None - assert file.file_handle.modified_on is not None - assert file.file_handle.concrete_type is not None - assert file.file_handle.content_type is not None - assert file.file_handle.content_md5 is not None - assert file.file_handle.file_name is not None - assert file.file_handle.storage_location_id is not None - assert file.file_handle.content_size is not None - assert file.file_handle.status is not None - assert file.file_handle.bucket_name is not None - assert file.file_handle.key is not None - assert file.file_handle.external_url is None - - -class TestChangeMetadata: - """Tests for the synapseclient.models.File.change_metadata method.""" - - @pytest.fixture(autouse=True, scope="function") - def init(self, syn: Synapse, schedule_for_cleanup: Callable[..., None]) -> None: - self.syn = syn - self.schedule_for_cleanup = schedule_for_cleanup - - @pytest.fixture(autouse=True, scope="function") - def file( - self, schedule_for_cleanup: Callable[..., None], project_model: Project - ) -> None: - filename = utils.make_bogus_uuid_file() - schedule_for_cleanup(filename) - file = File( - path=filename, - description=DESCRIPTION, - content_type=CONTENT_TYPE, - version_comment=VERSION_COMMENT, - version_label=str(uuid.uuid4()), - parent_id=project_model.id, - ) - return file - - def test_change_name( - self, file: File, schedule_for_cleanup: Callable[..., None] - ) -> None: - # GIVEN a file stored in synapse - file.store(synapse_client=self.syn) - schedule_for_cleanup(file.id) - assert file.id is not None - assert file.name is not None - assert file.content_type == CONTENT_TYPE - current_download_as = file.file_handle.file_name - - # WHEN I change the files metadata - new_filename = f"my_new_file_name_{str(uuid.uuid4())}.txt" - file.change_metadata(name=new_filename, synapse_client=self.syn) - - # THEN I expect only the file name to have been updated - assert file.file_handle.file_name == current_download_as - assert file.name == new_filename - assert file.content_type == CONTENT_TYPE - file_copy = File(id=file.id, download_file=False).get(synapse_client=self.syn) - assert file_copy.file_handle.file_name == current_download_as - assert file_copy.name == new_filename - assert file_copy.content_type == CONTENT_TYPE - - def test_change_content_type_and_download( - self, file: File, schedule_for_cleanup: Callable[..., None] - ) -> None: - # GIVEN a file stored in synapse - file.store(synapse_client=self.syn) - schedule_for_cleanup(file.id) - assert file.id is not None - assert file.name is not None - assert file.content_type == CONTENT_TYPE - current_filename = file.name - - # WHEN I change the files metadata - new_filename = f"my_new_file_name_{str(uuid.uuid4())}.txt" - file.change_metadata( - download_as=new_filename, - content_type=CONTENT_TYPE_JSON, - synapse_client=self.syn, - ) - - # THEN I expect the file download name to have been updated - assert file.file_handle.file_name == new_filename - assert file.name == current_filename - assert file.content_type == CONTENT_TYPE_JSON - file_copy = File(id=file.id, download_file=False).get(synapse_client=self.syn) - assert file_copy.file_handle.file_name == new_filename - assert file_copy.name == current_filename - assert file_copy.content_type == CONTENT_TYPE_JSON - - def test_change_content_type_and_download_and_name( - self, file: File, schedule_for_cleanup: Callable[..., None] - ) -> None: - # GIVEN a file stored in synapse - file.store(synapse_client=self.syn) - schedule_for_cleanup(file.id) - assert file.id is not None - assert file.name is not None - assert file.content_type == CONTENT_TYPE - - # WHEN I change the files metadata - new_filename = f"my_new_file_name_{str(uuid.uuid4())}.txt" - file.change_metadata( - name=new_filename, - download_as=new_filename, - content_type=CONTENT_TYPE_JSON, - synapse_client=self.syn, - ) - - # THEN I expect the file download name, entity name, and content type to have been updated - assert file.file_handle.file_name == new_filename - assert file.name == new_filename - assert file.content_type == CONTENT_TYPE_JSON - file_copy = File(id=file.id, download_file=False).get(synapse_client=self.syn) - assert file_copy.file_handle.file_name == new_filename - assert file_copy.name == new_filename - assert file_copy.content_type == CONTENT_TYPE_JSON - - -class TestFrom: - """Tests for the synapseclient.models.File.from_id and - synapseclient.models.File.from_path method.""" - - @pytest.fixture(autouse=True, scope="function") - def init(self, syn: Synapse, schedule_for_cleanup: Callable[..., None]) -> None: - self.syn = syn - self.schedule_for_cleanup = schedule_for_cleanup - - @pytest.fixture(autouse=True, scope="function") - def file( - self, schedule_for_cleanup: Callable[..., None], project_model: Project - ) -> File: - filename = utils.make_bogus_uuid_file() - schedule_for_cleanup(filename) - file = File( - path=filename, - description=DESCRIPTION, - content_type=CONTENT_TYPE, - version_comment=VERSION_COMMENT, - version_label=str(uuid.uuid4()), - parent_id=project_model.id, - ) - return file - - def test_from_id( - self, file: File, schedule_for_cleanup: Callable[..., None] - ) -> None: - # GIVEN a file stored in synapse - file.store(synapse_client=self.syn) - schedule_for_cleanup(file.id) - assert file.id is not None - - # WHEN I get the file by id - file_copy = File.from_id(file.id, synapse_client=self.syn) - - # THEN I expect the file to be returned - assert file_copy.id == file.id - - def test_from_path( - self, file: File, schedule_for_cleanup: Callable[..., None] - ) -> None: - # GIVEN a file stored in synapse - file.store(synapse_client=self.syn) - schedule_for_cleanup(file.id) - assert file.id is not None - assert file.path is not None - - # WHEN I get the file by path - file_copy = File.from_path(file.path, synapse_client=self.syn) - - # THEN I expect the file to be returned - assert file_copy.id == file.id - - -class TestDelete: - """Tests for the synapseclient.models.File.delete method.""" - - @pytest.fixture(autouse=True, scope="function") - def init(self, syn: Synapse, schedule_for_cleanup: Callable[..., None]) -> None: - self.syn = syn - self.schedule_for_cleanup = schedule_for_cleanup - - @pytest.fixture(autouse=True, scope="function") - def file( - self, schedule_for_cleanup: Callable[..., None], project_model: Project - ) -> File: - filename = utils.make_bogus_uuid_file() - schedule_for_cleanup(filename) - file = File( - path=filename, - description=DESCRIPTION, - content_type=CONTENT_TYPE, - version_comment=VERSION_COMMENT, - version_label=str(uuid.uuid4()), - parent_id=project_model.id, - ) - return file - - def test_delete( - self, file: File, schedule_for_cleanup: Callable[..., None] - ) -> None: - # GIVEN a file stored in synapse - file.store(synapse_client=self.syn) - schedule_for_cleanup(file.id) - assert file.id is not None - - # WHEN I delete the file - file.delete(synapse_client=self.syn) - - # THEN I expect the file to be deleted - with pytest.raises(SynapseHTTPError) as e: - file.get(synapse_client=self.syn) - assert f"404 Client Error: Entity {file.id} is in trash can." in str(e.value) - - def test_delete_specific_version( - self, file: File, schedule_for_cleanup: Callable[..., None] - ) -> None: - # GIVEN a file stored in synapse - file.store(synapse_client=self.syn) - schedule_for_cleanup(file.id) - assert file.id is not None - assert file.version_number == 1 - - # AND I update the file - file.description = "new description" - file.store(synapse_client=self.syn) - assert file.version_number == 2 - - # WHEN I delete the file for a specific version - File(id=file.id, version_number=1).delete( - version_only=True, synapse_client=self.syn - ) - - # THEN I expect the file to be deleted - with pytest.raises(SynapseHTTPError) as e: - File(id=file.id, version_number=1).get(synapse_client=self.syn) - assert ( - f"404 Client Error: Cannot find a node with id {file.id} and version 1" - in str(e.value) - ) - - # AND the second version to still exist - file_copy = File(id=file.id, version_number=2).get(synapse_client=self.syn) - assert file_copy.id == file.id - - -class TestGet: - """Tests for the synapseclient.models.File.get method.""" - - @pytest.fixture(autouse=True, scope="function") - def init(self, syn: Synapse, schedule_for_cleanup: Callable[..., None]) -> None: - self.syn = syn - self.schedule_for_cleanup = schedule_for_cleanup - - @pytest.fixture(autouse=True, scope="function") - def file( - self, schedule_for_cleanup: Callable[..., None], project_model: Project - ) -> File: - filename = utils.make_bogus_uuid_file() - schedule_for_cleanup(filename) - file = File( - path=filename, - description=DESCRIPTION, - content_type=CONTENT_TYPE, - version_comment=VERSION_COMMENT, - version_label=str(uuid.uuid4()), - parent_id=project_model.id, - ) - - return file - - def test_get_by_path( - self, file: File, schedule_for_cleanup: Callable[..., None] - ) -> None: - # GIVEN a file stored in synapse - file.store(synapse_client=self.syn) - schedule_for_cleanup(file.id) - assert file.id is not None - assert file.path is not None - - # WHEN I get the file by path - file_copy = File(path=file.path).get(synapse_client=self.syn) - - # THEN I expect the file to be returned - assert file_copy.id == file.id - - def test_get_by_id( - self, file: File, schedule_for_cleanup: Callable[..., None] - ) -> None: - # GIVEN a file stored in synapse - file.store(synapse_client=self.syn) - schedule_for_cleanup(file.id) - assert file.id is not None - assert file.path is not None - path_for_file = file.path - - # WHEN I get the file by id - file_copy = File(id=file.id).get(synapse_client=self.syn) - - # THEN I expect the file to be returned - assert file_copy.id == file.id - assert utils.equal_paths(file_copy.path, path_for_file) - - def test_get_previous_version( - self, file: File, schedule_for_cleanup: Callable[..., None] - ) -> None: - # GIVEN a file stored in synapse - file.store(synapse_client=self.syn) - schedule_for_cleanup(file.id) - assert file.id is not None - assert file.version_number == 1 - - # WHEN I update the file - file.store(synapse_client=self.syn) - - # AND I get the file by version - file_copy = File(id=file.id, version_number=1).get(synapse_client=self.syn) - - # THEN I expect the file to be returned - assert file_copy.id == file.id - assert file_copy.version_number == 1 - - def test_get_keep_both_for_matching_filenames( - self, file: File, schedule_for_cleanup: Callable[..., None] - ) -> None: - # GIVEN a file stored in synapse - file.store(synapse_client=self.syn) - schedule_for_cleanup(file.id) - assert file.id is not None - assert file.path is not None - assert file.name is not None - - # AND I store a second file in another location - filename = utils.make_bogus_uuid_file() - folder = Folder(parent_id=file.parent_id, name=str(uuid.uuid4())) - folder.store(synapse_client=self.syn) - file_2 = File( - path=filename, - name=file.name, - description=DESCRIPTION, - content_type=CONTENT_TYPE, - version_comment=VERSION_COMMENT, - version_label=str(uuid.uuid4()), - parent_id=folder.id, - ).store(synapse_client=self.syn) - self.schedule_for_cleanup(file_2.id) - - # AND I change the download name of the second file to the first file - file_2.change_metadata(download_as=file.name, synapse_client=self.syn) - - # WHEN I get the file with the default collision of `keep.both` - file_2 = File(id=file_2.id, path=os.path.dirname(file.path)).get( - synapse_client=self.syn - ) - - # THEN I expect both files to exist - assert file.path != file_2.path - assert os.path.exists(file.path) - assert os.path.exists(file_2.path) - - base_name, extension = os.path.splitext(os.path.basename(file.path)) - - # AND the second file to have a different path - assert os.path.basename(file_2.path) == f"{base_name}(1){extension}" - - def test_get_overwrite_local_for_matching_filenames( - self, file: File, schedule_for_cleanup: Callable[..., None] - ) -> None: - # GIVEN a file stored in synapse - file.store(synapse_client=self.syn) - schedule_for_cleanup(file.id) - assert file.id is not None - assert file.path is not None - assert file.name is not None - file_1_md5 = utils.md5_for_file(file.path).hexdigest() - - # AND I store a second file in another location - filename = utils.make_bogus_uuid_file() - file_2_md5 = utils.md5_for_file(filename).hexdigest() - folder = Folder(parent_id=file.parent_id, name=str(uuid.uuid4())) - folder.store(synapse_client=self.syn) - file_2 = File( - path=filename, - name=file.name, - description=DESCRIPTION, - content_type=CONTENT_TYPE, - version_comment=VERSION_COMMENT, - version_label=str(uuid.uuid4()), - parent_id=folder.id, - ).store(synapse_client=self.syn) - self.schedule_for_cleanup(file_2.id) - - # AND I change the download name of the second file to the first file - file_2.change_metadata(download_as=file.name, synapse_client=self.syn) - - # WHEN I get the file with the default collision of `overwrite.local` - file_2 = File( - id=file_2.id, - path=os.path.dirname(file.path), - if_collision="overwrite.local", - ).get(synapse_client=self.syn) - - # THEN I expect only the newer file to exist - assert os.path.exists(file_2.path) - assert file_1_md5 != file_2_md5 - assert utils.md5_for_file(file_2.path).hexdigest() == file_2_md5 - - def test_get_keep_local_for_matching_filenames( - self, file: File, schedule_for_cleanup: Callable[..., None] - ) -> None: - # GIVEN a file stored in synapse - file.store(synapse_client=self.syn) - schedule_for_cleanup(file.id) - assert file.id is not None - assert file.path is not None - assert file.name is not None - file_1_md5 = utils.md5_for_file(file.path).hexdigest() - - # AND I store a second file in another location - filename = utils.make_bogus_uuid_file() - file_2_md5 = utils.md5_for_file(filename).hexdigest() - folder = Folder(parent_id=file.parent_id, name=str(uuid.uuid4())) - folder.store(synapse_client=self.syn) - file_2 = File( - path=filename, - name=file.name, - description=DESCRIPTION, - content_type=CONTENT_TYPE, - version_comment=VERSION_COMMENT, - version_label=str(uuid.uuid4()), - parent_id=folder.id, - ).store(synapse_client=self.syn) - self.schedule_for_cleanup(file_2.id) - - # AND I change the download name of the second file to the first file - file_2.change_metadata(download_as=file.name, synapse_client=self.syn) - - # WHEN I get the file with the default collision of `keep.local` - file_2 = File( - id=file_2.id, - path=os.path.dirname(file.path), - if_collision="keep.local", - ).get(synapse_client=self.syn) - - # THEN I expect only the newer file to exist - assert file_2.path is None - assert os.path.exists(file.path) - assert file_1_md5 != file_2_md5 - assert utils.md5_for_file(file.path).hexdigest() == file_1_md5 - - def test_get_by_path_limit_search( - self, file: File, schedule_for_cleanup: Callable[..., None] - ) -> None: - # GIVEN a file stored in synapse - file.store(synapse_client=self.syn) - schedule_for_cleanup(file.id) - assert file.id is not None - assert file.path is not None - - # AND I store a copy of the file in a folder - folder = Folder(parent_id=file.parent_id, name=str(uuid.uuid4())) - folder.store(synapse_client=self.syn) - self.schedule_for_cleanup(folder.id) - file_copy = file.copy(parent_id=folder.id, synapse_client=self.syn) - - # WHEN I get the file by path and limit the search to the folder - file_by_path = File( - path=file.path, synapse_container_limit=folder.id, download_file=False - ).get(synapse_client=self.syn) - - # THEN I expect the file in the folder to be returned - assert file_by_path.id == file_copy.id - - # WHEN I get the file by path and limit the search to the project - file_by_path = File( - path=file.path, synapse_container_limit=file.parent_id, download_file=False - ).get(synapse_client=self.syn) - - # THEN I expect the file at the project level to be returned - assert file.id == file_by_path.id - - -class TestCopy: - """Tests for the synapseclient.models.File.copy method.""" - - @pytest.fixture(autouse=True, scope="function") - def init(self, syn: Synapse, schedule_for_cleanup: Callable[..., None]) -> None: - self.syn = syn - self.schedule_for_cleanup = schedule_for_cleanup - - @pytest.fixture(autouse=True, scope="function") - def file( - self, schedule_for_cleanup: Callable[..., None], project_model: Project - ) -> File: - filename = utils.make_bogus_uuid_file() - schedule_for_cleanup(filename) - file = File( - path=filename, - description=DESCRIPTION, - content_type=CONTENT_TYPE, - version_comment=VERSION_COMMENT, - version_label=str(uuid.uuid4()), - parent_id=project_model.id, - ) - - return file - - def test_copy_same_path( - self, file: File, schedule_for_cleanup: Callable[..., None] - ) -> None: - # GIVEN a file stored in synapse - file.store(synapse_client=self.syn) - schedule_for_cleanup(file.id) - assert file.id is not None - assert file.path is not None - - # AND I store a copy of the file in a folder - folder = Folder(parent_id=file.parent_id, name=str(uuid.uuid4())) - folder.store(synapse_client=self.syn) - self.schedule_for_cleanup(folder.id) - file_copy = file.copy(parent_id=folder.id, synapse_client=self.syn) - - # WHEN I get both files by ID: - file_1 = File(id=file.id, download_file=False).get(synapse_client=self.syn) - file_2 = File(id=file_copy.id, download_file=False).get(synapse_client=self.syn) - - # THEN I expect the paths to be the same file - assert file_1.path == file_2.path - - def test_copy_annotations_and_activity( - self, file: File, schedule_for_cleanup: Callable[..., None] - ) -> None: - # GIVEN a file stored in synapse - file.store(synapse_client=self.syn) - schedule_for_cleanup(file.id) - assert file.id is not None - assert file.path is not None - - # AND I store activites and annotations on the file - activity = Activity( - name="some_name", - description="some_description", - used=[ - UsedURL(name="example", url=BOGUS_URL), - UsedEntity(target_id="syn456", target_version_number=1), - ], - executed=[ - UsedURL(name="example", url=BOGUS_URL), - UsedEntity(target_id="syn789", target_version_number=1), - ], - ) - annotations_for_my_file = { - "my_single_key_string": "a", - "my_key_string": ["b", "a", "c"], - "my_key_bool": [False, False, False], - "my_key_double": [1.2, 3.4, 5.6], - "my_key_long": [1, 2, 3], - } - file.activity = activity - file.annotations = annotations_for_my_file - file.store(synapse_client=self.syn) - - # AND I store a copy of the file in a folder - folder = Folder(parent_id=file.parent_id, name=str(uuid.uuid4())) - folder.store(synapse_client=self.syn) - self.schedule_for_cleanup(folder.id) - file_copy = file.copy(parent_id=folder.id, synapse_client=self.syn) - - # WHEN I get both files by ID: - file_1 = File(id=file.id, download_file=False).get(synapse_client=self.syn) - file_2 = File(id=file_copy.id, download_file=False).get(synapse_client=self.syn) - - # THEN I expect the activities and annotations to be the same - assert file_1.annotations == file_2.annotations - assert file_1.activity == file_2.activity - - def test_copy_activity_only( - self, file: File, schedule_for_cleanup: Callable[..., None] - ) -> None: - # GIVEN a file stored in synapse - file.store(synapse_client=self.syn) - schedule_for_cleanup(file.id) - assert file.id is not None - assert file.path is not None - - # AND I store activites and annotations on the file - activity = Activity( - name="some_name", - description="some_description", - used=[ - UsedURL(name="example", url=BOGUS_URL), - UsedEntity(target_id="syn456", target_version_number=1), - ], - executed=[ - UsedURL(name="example", url=BOGUS_URL), - UsedEntity(target_id="syn789", target_version_number=1), - ], - ) - annotations_for_my_file = { - "my_single_key_string": "a", - "my_key_string": ["b", "a", "c"], - "my_key_bool": [False, False, False], - "my_key_double": [1.2, 3.4, 5.6], - "my_key_long": [1, 2, 3], - } - file.activity = activity - file.annotations = annotations_for_my_file - file.store(synapse_client=self.syn) - - # AND I store a copy of the file in a folder - folder = Folder(parent_id=file.parent_id, name=str(uuid.uuid4())) - folder.store(synapse_client=self.syn) - self.schedule_for_cleanup(folder.id) - file_copy = file.copy( - parent_id=folder.id, copy_annotations=False, synapse_client=self.syn - ) - - # WHEN I get both files by ID: - file_1 = File(id=file.id, download_file=False).get(synapse_client=self.syn) - file_2 = File(id=file_copy.id, download_file=False).get(synapse_client=self.syn) - - # THEN I expect the activities to be the same and annotations on the second to be None - assert file_1.annotations != file_2.annotations - assert not file_2.annotations and isinstance(file_2.annotations, dict) - assert file_1.activity == file_2.activity - - def test_copy_with_no_activity_or_annotations( - self, file: File, schedule_for_cleanup: Callable[..., None] - ) -> None: - # GIVEN a file stored in synapse - file.store(synapse_client=self.syn) - schedule_for_cleanup(file.id) - assert file.id is not None - assert file.path is not None - - # AND I store activites and annotations on the file - activity = Activity( - name="some_name", - description="some_description", - used=[ - UsedURL(name="example", url=BOGUS_URL), - UsedEntity(target_id="syn456", target_version_number=1), - ], - executed=[ - UsedURL(name="example", url=BOGUS_URL), - UsedEntity(target_id="syn789", target_version_number=1), - ], - ) - annotations_for_my_file = { - "my_single_key_string": "a", - "my_key_string": ["b", "a", "c"], - "my_key_bool": [False, False, False], - "my_key_double": [1.2, 3.4, 5.6], - "my_key_long": [1, 2, 3], - } - file.activity = activity - file.annotations = annotations_for_my_file - file.store(synapse_client=self.syn) - - # AND I store a copy of the file in a folder - folder = Folder(parent_id=file.parent_id, name=str(uuid.uuid4())) - folder.store(synapse_client=self.syn) - self.schedule_for_cleanup(folder.id) - file_copy = file.copy( - parent_id=folder.id, - copy_annotations=False, - copy_activity=None, - synapse_client=self.syn, - ) - - # WHEN I get both files by ID: - file_1 = File(id=file.id, download_file=False).get( - include_activity=True, synapse_client=self.syn - ) - file_2 = File(id=file_copy.id, download_file=False).get( - include_activity=True, synapse_client=self.syn - ) - - # THEN I expect the activities to be the same and annotations on the second to be None - assert file_1.annotations != file_2.annotations - assert file_1.activity != file_2.activity - assert not file_2.annotations and isinstance(file_2.annotations, dict) - assert file_2.activity is None - - def test_copy_previous_version( - self, file: File, schedule_for_cleanup: Callable[..., None] - ) -> None: - # GIVEN a file stored in synapse - file.store(synapse_client=self.syn) - schedule_for_cleanup(file.id) - assert file.id is not None - assert file.path is not None - assert file.version_number == 1 - file_first_md5 = utils.md5_for_file(file.path).hexdigest() - - # AND the file MD5 is updated - filename = utils.make_bogus_uuid_file() - self.schedule_for_cleanup(filename) - file.path = filename - file.store(synapse_client=self.syn) - assert file.version_number == 2 - second_first_md5 = utils.md5_for_file(file.path).hexdigest() - - # WHEN I store a copy of the first version_number of the file in a folder - folder = Folder(parent_id=file.parent_id, name=str(uuid.uuid4())) - folder.store(synapse_client=self.syn) - self.schedule_for_cleanup(folder.id) - file_copy = File(id=file.id, version_number=1).copy( - parent_id=folder.id, - copy_annotations=False, - copy_activity=None, - synapse_client=self.syn, - ) - - # THEN I expect the first version of the file to have been stored - assert file_copy.file_handle.content_md5 == file_first_md5 - assert second_first_md5 != file_first_md5 diff --git a/tests/integration/synapseclient/models/synchronous/test_folder.py b/tests/integration/synapseclient/models/synchronous/test_folder.py deleted file mode 100644 index deaef8bd5..000000000 --- a/tests/integration/synapseclient/models/synchronous/test_folder.py +++ /dev/null @@ -1,791 +0,0 @@ -"""Integration tests for the synapseclient.models.Folder class.""" - -import os -import uuid -from typing import Callable, List - -import pytest - -from synapseclient import Synapse -from synapseclient.core import utils -from synapseclient.core.exceptions import SynapseHTTPError -from synapseclient.models import File, Folder, Project - -DESCRIPTION_FOLDER = "This is an example folder." -DESCRIPTION_FILE = "This is an example file." -CONTENT_TYPE = "text/plain" - - -class TestFolderStore: - """Tests for the synapseclient.models.Folder.store method.""" - - @pytest.fixture(autouse=True, scope="function") - def init(self, syn: Synapse, schedule_for_cleanup: Callable[..., None]) -> None: - self.syn = syn - self.schedule_for_cleanup = schedule_for_cleanup - - def create_file_instance(self, schedule_for_cleanup: Callable[..., None]) -> File: - filename = utils.make_bogus_uuid_file() - schedule_for_cleanup(filename) - return File( - path=filename, - description=DESCRIPTION_FILE, - content_type=CONTENT_TYPE, - ) - - def create_files(self, count: int) -> List[File]: - """Helper method to create multiple file instances""" - return [ - self.create_file_instance(self.schedule_for_cleanup) for _ in range(count) - ] - - @pytest.fixture(autouse=True, scope="function") - def file(self, schedule_for_cleanup: Callable[..., None]) -> File: - return self.create_file_instance(schedule_for_cleanup) - - @pytest.fixture(autouse=True, scope="function") - def folder(self) -> Folder: - folder = Folder(name=str(uuid.uuid4()), description=DESCRIPTION_FOLDER) - return folder - - def verify_folder_properties( - self, - folder: Folder, - parent_id: str, - expected_files: list = None, - expected_folders: list = None, - ): - """Helper method to verify folder properties""" - assert folder.id is not None - assert folder.name is not None - assert folder.parent_id == parent_id - assert folder.description is not None - assert folder.etag is not None - assert folder.created_on is not None - assert folder.modified_on is not None - assert folder.created_by is not None - assert folder.modified_by is not None - - if expected_files is None: - assert folder.files == [] - else: - assert folder.files == expected_files - # Verify files properties - for file in folder.files: - assert file.id is not None - assert file.name is not None - assert file.parent_id == folder.id - assert file.path is not None - - if expected_folders is None: - assert folder.folders == [] - else: - assert folder.folders == expected_folders - # Verify sub-folders properties - for sub_folder in folder.folders: - assert sub_folder.id is not None - assert sub_folder.name is not None - assert sub_folder.parent_id == folder.id - # Verify files in sub-folders - for sub_file in sub_folder.files: - assert sub_file.id is not None - assert sub_file.name is not None - assert sub_file.parent_id == sub_folder.id - assert sub_file.path is not None - - assert isinstance(folder.annotations, dict) - - def test_store_folder_variations( - self, project_model: Project, folder: Folder - ) -> None: - # Test Case 1: Simple folder storage - # GIVEN a Folder object and a Project object - - # WHEN I store the Folder on Synapse - stored_folder = folder.store(parent=project_model, synapse_client=self.syn) - self.schedule_for_cleanup(folder.id) - - # THEN I expect the stored Folder to have the expected properties - self.verify_folder_properties(stored_folder, project_model.id) - assert not stored_folder.annotations - - # Test Case 2: Folder with annotations - # GIVEN a Folder object with annotations - folder_with_annotations = Folder( - name=str(uuid.uuid4()), description=DESCRIPTION_FOLDER - ) - annotations = { - "my_single_key_string": ["a"], - "my_key_string": ["b", "a", "c"], - "my_key_bool": [False, False, False], - "my_key_double": [1.2, 3.4, 5.6], - "my_key_long": [1, 2, 3], - } - folder_with_annotations.annotations = annotations - - # WHEN I store the Folder on Synapse - stored_folder_with_annotations = folder_with_annotations.store( - parent=project_model, synapse_client=self.syn - ) - self.schedule_for_cleanup(folder_with_annotations.id) - - # THEN I expect the stored Folder to have the expected properties and annotations - self.verify_folder_properties(stored_folder_with_annotations, project_model.id) - assert stored_folder_with_annotations.annotations == annotations - assert ( - Folder(id=stored_folder_with_annotations.id).get(synapse_client=self.syn) - ).annotations == annotations - - def test_store_folder_with_files( - self, project_model: Project, file: File, folder: Folder - ) -> None: - # Test Case 1: Folder with a single file - # GIVEN a File on the folder - folder.files.append(file) - - # WHEN I store the Folder on Synapse - stored_folder = folder.store(parent=project_model, synapse_client=self.syn) - self.schedule_for_cleanup(folder.id) - - # THEN I expect the stored Folder to have the expected properties and files - self.verify_folder_properties( - stored_folder, project_model.id, expected_files=[file] - ) - - # Test Case 2: Folder with multiple files - # GIVEN a folder with multiple files - folder_with_multiple_files = Folder( - name=str(uuid.uuid4()), description=DESCRIPTION_FOLDER - ) - files = self.create_files(3) - folder_with_multiple_files.files = files - - # WHEN I store the Folder on Synapse - stored_folder_with_multiple_files = folder_with_multiple_files.store( - parent=project_model, synapse_client=self.syn - ) - self.schedule_for_cleanup(folder_with_multiple_files.id) - - # THEN I expect the stored Folder to have the expected properties and files - self.verify_folder_properties( - stored_folder_with_multiple_files, project_model.id, expected_files=files - ) - - def test_store_folder_with_files_and_folders( - self, project_model: Project, folder: Folder - ) -> None: - # GIVEN a folder with nested structure (files and sub-folders with files) - files = self.create_files(3) - folder.files = files - - # Create sub-folders with files - folders = [] - for _ in range(2): - sub_folder = Folder(name=str(uuid.uuid4())) - sub_folder.files = self.create_files(2) - folders.append(sub_folder) - folder.folders = folders - - # WHEN I store the Folder on Synapse - stored_folder = folder.store(parent=project_model, synapse_client=self.syn) - self.schedule_for_cleanup(folder.id) - - # THEN I expect the stored Folder to have the expected properties, files, and folders - self.verify_folder_properties( - stored_folder, - project_model.id, - expected_files=files, - expected_folders=folders, - ) - - -class TestFolderGetDelete: - """Tests for the synapseclient.models.Folder.get and delete methods.""" - - @pytest.fixture(autouse=True, scope="function") - def init(self, syn: Synapse, schedule_for_cleanup: Callable[..., None]) -> None: - self.syn = syn - self.schedule_for_cleanup = schedule_for_cleanup - - @pytest.fixture(autouse=True, scope="function") - def folder(self) -> Folder: - folder = Folder(name=str(uuid.uuid4()), description=DESCRIPTION_FOLDER) - return folder - - def test_get_folder_methods(self, project_model: Project, folder: Folder) -> None: - # GIVEN a Folder object stored in Synapse - stored_folder = folder.store(parent=project_model, synapse_client=self.syn) - self.schedule_for_cleanup(folder.id) - - # Test Case 1: Get folder by ID - # WHEN I get the Folder from Synapse by ID - folder_by_id = Folder(id=stored_folder.id).get(synapse_client=self.syn) - - # THEN I expect the retrieved Folder to have the expected properties - self.verify_folder_properties(folder_by_id, project_model.id) - - # Test Case 2: Get folder by name and parent_id attribute - # WHEN I get the Folder from Synapse by name and parent_id - folder_by_name_parent_id = Folder( - name=stored_folder.name, parent_id=stored_folder.parent_id - ).get(synapse_client=self.syn) - - # THEN I expect the retrieved Folder to have the expected properties - self.verify_folder_properties(folder_by_name_parent_id, project_model.id) - - # Test Case 3: Get folder by name and parent object - # WHEN I get the Folder from Synapse by name and parent object - folder_by_name_parent = Folder(name=stored_folder.name).get( - parent=project_model, synapse_client=self.syn - ) - - # THEN I expect the retrieved Folder to have the expected properties - self.verify_folder_properties(folder_by_name_parent, project_model.id) - - def verify_folder_properties(self, folder: Folder, parent_id: str): - """Helper method to verify folder properties""" - assert folder.id is not None - assert folder.name is not None - assert folder.parent_id == parent_id - assert folder.description is not None - assert folder.etag is not None - assert folder.created_on is not None - assert folder.modified_on is not None - assert folder.created_by is not None - assert folder.modified_by is not None - assert folder.files == [] - assert folder.folders == [] - assert not folder.annotations and isinstance(folder.annotations, dict) - - def test_delete_folder(self, project_model: Project, folder: Folder) -> None: - # GIVEN a Folder object stored in Synapse - stored_folder = folder.store(parent=project_model, synapse_client=self.syn) - self.schedule_for_cleanup(folder.id) - - # WHEN I delete the Folder from Synapse - stored_folder.delete(synapse_client=self.syn) - - # THEN I expect the folder to have been deleted - with pytest.raises(SynapseHTTPError) as e: - stored_folder.get(synapse_client=self.syn) - - assert f"404 Client Error: Entity {stored_folder.id} is in trash can." in str( - e.value - ) - - -class TestFolderCopy: - """Tests for the synapseclient.models.Folder.copy method.""" - - @pytest.fixture(autouse=True, scope="function") - def init(self, syn: Synapse, schedule_for_cleanup: Callable[..., None]) -> None: - self.syn = syn - self.schedule_for_cleanup = schedule_for_cleanup - - def create_file_instance(self, schedule_for_cleanup: Callable[..., None]) -> File: - filename = utils.make_bogus_uuid_file() - schedule_for_cleanup(filename) - return File( - path=filename, - description=DESCRIPTION_FILE, - content_type=CONTENT_TYPE, - ) - - def create_files(self, count: int) -> List[File]: - """Helper method to create multiple file instances""" - return [ - self.create_file_instance(self.schedule_for_cleanup) for _ in range(count) - ] - - @pytest.fixture(autouse=True, scope="function") - def folder(self) -> Folder: - folder = Folder(name=str(uuid.uuid4()), description=DESCRIPTION_FOLDER) - return folder - - def create_nested_folder(self) -> Folder: - """Helper method to create a folder with files and subfolders""" - folder = Folder(name=str(uuid.uuid4()), description=DESCRIPTION_FOLDER) - - # Add files to folder - folder.files = self.create_files(3) - - # Add subfolders with files - folders = [] - for _ in range(2): - sub_folder = Folder(name=str(uuid.uuid4())) - sub_folder.files = self.create_files(2) - folders.append(sub_folder) - folder.folders = folders - - return folder - - def verify_copied_folder( - self, - copied_folder: Folder, - original_folder: Folder, - expected_files_empty: bool = False, - ): - """Helper method to verify copied folder properties""" - assert copied_folder.id is not None - assert copied_folder.id != original_folder.id - assert copied_folder.name == original_folder.name - assert copied_folder.parent_id != original_folder.parent_id - assert copied_folder.description == original_folder.description - assert copied_folder.etag is not None - assert copied_folder.created_on is not None - assert copied_folder.modified_on is not None - assert copied_folder.created_by is not None - assert copied_folder.modified_by is not None - assert copied_folder.annotations == original_folder.annotations - - if expected_files_empty: - assert copied_folder.files == [] - else: - assert len(copied_folder.files) == len(original_folder.files) - for file in copied_folder.files: - assert file.id is not None - assert file.name is not None - assert file.parent_id == copied_folder.id - - if len(copied_folder.folders) > 0: - for i, sub_folder in enumerate(copied_folder.folders): - assert sub_folder.id is not None - assert sub_folder.name is not None - assert sub_folder.parent_id == copied_folder.id - - if expected_files_empty: - assert sub_folder.files == [] - else: - for sub_file in sub_folder.files: - assert sub_file.id is not None - assert sub_file.name is not None - assert sub_file.parent_id == sub_folder.id - - def test_copy_folder_with_files_and_folders(self, project_model: Project) -> None: - # GIVEN a nested folder structure with files and folders - source_folder = self.create_nested_folder() - source_folder.annotations = {"test": ["test"]} - stored_source_folder = source_folder.store( - parent=project_model, synapse_client=self.syn - ) - self.schedule_for_cleanup(stored_source_folder.id) - - # Test Case 1: Copy folder with all contents - # Create first destination folder - destination_folder_1 = Folder( - name=str(uuid.uuid4()), description="Destination for folder copy 1" - ).store(parent=project_model, synapse_client=self.syn) - self.schedule_for_cleanup(destination_folder_1.id) - - # WHEN I copy the folder to the destination folder - copied_folder = stored_source_folder.copy( - parent_id=destination_folder_1.id, synapse_client=self.syn - ) - - # AND I sync the destination folder from Synapse - destination_folder_1.sync_from_synapse( - recursive=False, download_file=False, synapse_client=self.syn - ) - - # THEN I expect the copied Folder to have the expected properties - assert len(destination_folder_1.folders) == 1 - assert destination_folder_1.folders == [copied_folder] - self.verify_copied_folder(copied_folder, stored_source_folder) - - # Test Case 2: Copy folder excluding files - # Create a second destination folder for the second test case - destination_folder_2 = Folder( - name=str(uuid.uuid4()), description="Destination for folder copy 2" - ).store(parent=project_model, synapse_client=self.syn) - self.schedule_for_cleanup(destination_folder_2.id) - - # WHEN I copy the folder to the destination folder excluding files - copied_folder_no_files = stored_source_folder.copy( - parent_id=destination_folder_2.id, - exclude_types=["file"], - synapse_client=self.syn, - ) - - # AND I sync the destination folder from Synapse - destination_folder_2.sync_from_synapse( - recursive=False, download_file=False, synapse_client=self.syn - ) - - # THEN I expect the copied Folder to have the expected properties but no files - assert len(destination_folder_2.folders) == 1 - assert destination_folder_2.folders == [copied_folder_no_files] - self.verify_copied_folder( - copied_folder_no_files, stored_source_folder, expected_files_empty=True - ) - - -class TestFolderSyncFromSynapse: - """Tests for the synapseclient.models.Folder.sync_from_synapse method.""" - - @pytest.fixture(autouse=True, scope="function") - def init(self, syn: Synapse, schedule_for_cleanup: Callable[..., None]) -> None: - self.syn = syn - self.schedule_for_cleanup = schedule_for_cleanup - - def create_file_instance(self, schedule_for_cleanup: Callable[..., None]) -> File: - filename = utils.make_bogus_uuid_file() - schedule_for_cleanup(filename) - return File( - path=filename, - description=DESCRIPTION_FILE, - content_type=CONTENT_TYPE, - ) - - def create_files(self, count: int) -> List[File]: - """Helper method to create multiple file instances""" - return [ - self.create_file_instance(self.schedule_for_cleanup) for _ in range(count) - ] - - @pytest.fixture(autouse=True, scope="function") - def file(self, schedule_for_cleanup: Callable[..., None]) -> File: - return self.create_file_instance(schedule_for_cleanup) - - @pytest.fixture(autouse=True, scope="function") - def folder(self) -> Folder: - folder = Folder(name=str(uuid.uuid4()), description=DESCRIPTION_FOLDER) - return folder - - def test_sync_from_synapse( - self, project_model: Project, file: File, folder: Folder - ) -> None: - # GIVEN a nested folder structure with files and folders - root_directory_path = os.path.dirname(file.path) - - # Add files to folder - folder.files = self.create_files(3) - - # Add subfolders with files - sub_folders = [] - for _ in range(2): - sub_folder = Folder(name=str(uuid.uuid4())) - sub_folder.files = self.create_files(2) - sub_folders.append(sub_folder) - folder.folders = sub_folders - - # WHEN I store the Folder on Synapse - stored_folder = folder.store(parent=project_model, synapse_client=self.syn) - self.schedule_for_cleanup(folder.id) - - # AND I sync the folder from Synapse - copied_folder = stored_folder.sync_from_synapse( - path=root_directory_path, synapse_client=self.syn - ) - - # THEN I expect that the folder and its contents are synced from Synapse to disk - # Verify files in root folder - for file in copied_folder.files: - assert os.path.exists(file.path) - assert os.path.isfile(file.path) - assert ( - utils.md5_for_file(file.path).hexdigest() - == file.file_handle.content_md5 - ) - - # Verify subfolders and their files - for sub_folder in stored_folder.folders: - resolved_path = os.path.join(root_directory_path, sub_folder.name) - assert os.path.exists(resolved_path) - assert os.path.isdir(resolved_path) - - for sub_file in sub_folder.files: - assert os.path.exists(sub_file.path) - assert os.path.isfile(sub_file.path) - assert ( - utils.md5_for_file(sub_file.path).hexdigest() - == sub_file.file_handle.content_md5 - ) - - def test_sync_all_entity_types(self, project_model: Project) -> None: - """Test syncing a folder with all supported entity types.""" - # GIVEN a folder with one of each entity type - from synapseclient.models import ( - Column, - ColumnType, - Dataset, - DatasetCollection, - EntityRef, - EntityView, - File, - MaterializedView, - SubmissionView, - Table, - ViewTypeMask, - VirtualTable, - ) - - # Create the folder first - folder = Folder( - name=f"test_folder_{str(uuid.uuid4())}", - parent_id=project_model.id, - ) - folder = folder.store(synapse_client=self.syn) - self.schedule_for_cleanup(folder.id) - - # Create and store a File - file = File( - name=f"test_file_{str(uuid.uuid4())}.txt", - parent_id=folder.id, - path=utils.make_bogus_uuid_file(), - ) - file = file.store(synapse_client=self.syn) - self.schedule_for_cleanup(file.id) - - # Create and store a nested Folder - nested_folder = Folder( - name=f"test_nested_folder_{str(uuid.uuid4())}", - parent_id=folder.id, - ) - nested_folder = nested_folder.store(synapse_client=self.syn) - self.schedule_for_cleanup(nested_folder.id) - - # Create and store a Table - table = Table( - name=f"test_table_{str(uuid.uuid4())}", - parent_id=folder.id, - columns=[ - Column(name="test_column", column_type=ColumnType.STRING), - Column(name="test_column2", column_type=ColumnType.INTEGER), - ], - ) - table = table.store(synapse_client=self.syn) - self.schedule_for_cleanup(table.id) - - # Create and store an EntityView - entity_view = EntityView( - name=f"test_entityview_{str(uuid.uuid4())}", - parent_id=folder.id, - scope_ids=[folder.id], - view_type_mask=ViewTypeMask.FILE, - include_default_columns=True, - ) - entity_view = entity_view.store(synapse_client=self.syn) - self.schedule_for_cleanup(entity_view.id) - - # Create and store a MaterializedView - materialized_view = MaterializedView( - name=f"test_materializedview_{str(uuid.uuid4())}", - parent_id=folder.id, - defining_sql=f"SELECT * FROM {table.id}", - ) - materialized_view = materialized_view.store(synapse_client=self.syn) - self.schedule_for_cleanup(materialized_view.id) - - # Create and store a VirtualTable - virtual_table = VirtualTable( - name=f"test_virtualtable_{str(uuid.uuid4())}", - parent_id=folder.id, - defining_sql=f"SELECT * FROM {table.id}", - ) - virtual_table = virtual_table.store(synapse_client=self.syn) - self.schedule_for_cleanup(virtual_table.id) - - # Create and store a Dataset (reusing the existing file) - dataset = Dataset( - name=f"test_dataset_{str(uuid.uuid4())}", - parent_id=folder.id, - items=[EntityRef(id=file.id, version=1)], - ) - dataset = dataset.store(synapse_client=self.syn) - self.schedule_for_cleanup(dataset.id) - - # Create and store a DatasetCollection - dataset_collection = DatasetCollection( - name=f"test_datasetcollection_{str(uuid.uuid4())}", - parent_id=folder.id, - items=[EntityRef(id=dataset.id, version=1)], - ) - dataset_collection = dataset_collection.store(synapse_client=self.syn) - self.schedule_for_cleanup(dataset_collection.id) - - # Create and store a SubmissionView - submission_view = SubmissionView( - name=f"test_submissionview_{str(uuid.uuid4())}", - parent_id=folder.id, - scope_ids=[folder.id], - ) - submission_view = submission_view.store(synapse_client=self.syn) - self.schedule_for_cleanup(submission_view) - - # WHEN I sync the folder from Synapse - synced_folder = folder.sync_from_synapse( - recursive=False, download_file=False, synapse_client=self.syn - ) - - # THEN all entity types should be present - assert len(synced_folder.files) == 1 - assert synced_folder.files[0].id == file.id - assert synced_folder.files[0].name == file.name - - assert len(synced_folder.folders) == 1 - assert synced_folder.folders[0].id == nested_folder.id - assert synced_folder.folders[0].name == nested_folder.name - - assert len(synced_folder.tables) == 1 - assert synced_folder.tables[0].id == table.id - assert synced_folder.tables[0].name == table.name - - assert len(synced_folder.entityviews) == 1 - assert synced_folder.entityviews[0].id == entity_view.id - assert synced_folder.entityviews[0].name == entity_view.name - - assert len(synced_folder.materializedviews) == 1 - assert synced_folder.materializedviews[0].id == materialized_view.id - assert synced_folder.materializedviews[0].name == materialized_view.name - - assert len(synced_folder.virtualtables) == 1 - assert synced_folder.virtualtables[0].id == virtual_table.id - assert synced_folder.virtualtables[0].name == virtual_table.name - - assert len(synced_folder.datasets) == 1 - assert synced_folder.datasets[0].id == dataset.id - assert synced_folder.datasets[0].name == dataset.name - - assert len(synced_folder.datasetcollections) == 1 - assert synced_folder.datasetcollections[0].id == dataset_collection.id - assert synced_folder.datasetcollections[0].name == dataset_collection.name - - assert len(synced_folder.submissionviews) == 1 - assert synced_folder.submissionviews[0].id == submission_view.id - assert synced_folder.submissionviews[0].name == submission_view.name - - -class TestFolderWalk: - """Tests for the synapseclient.models.Folder.walk methods.""" - - @pytest.fixture(autouse=True, scope="function") - def init( - self, syn_with_logger: Synapse, schedule_for_cleanup: Callable[..., None] - ) -> None: - self.syn = syn_with_logger - self.schedule_for_cleanup = schedule_for_cleanup - - def create_file_instance(self, schedule_for_cleanup: Callable[..., None]) -> File: - filename = utils.make_bogus_uuid_file() - schedule_for_cleanup(filename) - return File( - path=filename, - description=DESCRIPTION_FILE, - content_type=CONTENT_TYPE, - ) - - def create_test_hierarchy(self, project_model: Project) -> dict: - """Create a test hierarchy for walk testing.""" - # Store the parent folder first - folder = Folder( - name=f"test_walk_folder_{str(uuid.uuid4())}", parent_id=project_model.id - ) - folder = folder.store(synapse_client=self.syn) - self.schedule_for_cleanup(folder.id) - - # Create a file in the root folder - root_file = self.create_file_instance(self.schedule_for_cleanup) - root_file.parent_id = folder.id - root_file = root_file.store(synapse_client=self.syn) - self.schedule_for_cleanup(root_file.id) - - # Create nested folder and file - nested_folder = Folder(name=f"nested_folder_{str(uuid.uuid4())[:8]}") - nested_folder.parent_id = folder.id - nested_folder = nested_folder.store(synapse_client=self.syn) - self.schedule_for_cleanup(nested_folder.id) - - nested_file = self.create_file_instance(self.schedule_for_cleanup) - nested_file.parent_id = nested_folder.id - nested_file = nested_file.store(synapse_client=self.syn) - self.schedule_for_cleanup(nested_file.id) - - # Create another nested folder with no files - empty_folder = Folder(name=f"empty_folder_{str(uuid.uuid4())[:8]}") - empty_folder.parent_id = folder.id - empty_folder = empty_folder.store(synapse_client=self.syn) - self.schedule_for_cleanup(empty_folder.id) - - return { - "folder": folder, - "root_file": root_file, - "nested_folder": nested_folder, - "nested_file": nested_file, - "empty_folder": empty_folder, - } - - def test_walk_recursive_true(self, project_model: Project) -> None: - """Test walk method with recursive=True.""" - # GIVEN: A folder with a hierarchical structure - hierarchy = self.create_test_hierarchy(project_model) - - # WHEN: Walking through the folder with recursive=True - results = list( - hierarchy["folder"].walk(recursive=True, synapse_client=self.syn) - ) - - # THEN: Should get 3 results (folder root, nested_folder, empty_folder) - assert len(results) == 3 - - # AND: Folder root result should contain correct structure - folder_result = results[0] - dirpath, dirs, nondirs = folder_result - assert dirpath[0] == hierarchy["folder"].name - assert dirpath[1] == hierarchy["folder"].id - assert len(dirs) == 2 # nested_folder and empty_folder - assert len(nondirs) == 1 # root_file - - # AND: All returned objects should be EntityHeader instances - assert hasattr(dirs[0], "name") - assert hasattr(dirs[0], "id") - assert hasattr(dirs[0], "type") - assert hasattr(nondirs[0], "name") - assert hasattr(nondirs[0], "id") - assert hasattr(nondirs[0], "type") - - # AND: Should be able to find nested content - nested_results = [r for r in results if "nested_folder" in r[0][0]] - assert len(nested_results) == 1 - _, nested_dirs, nested_nondirs = nested_results[0] - assert len(nested_dirs) == 0 - assert len(nested_nondirs) == 1 # nested_file - - # AND: Nested objects should also be EntityHeader instances - assert hasattr(nested_nondirs[0], "name") - assert hasattr(nested_nondirs[0], "id") - assert hasattr(nested_nondirs[0], "type") - - # AND: Should be able to find empty folder - empty_results = [r for r in results if "empty_folder" in r[0][0]] - assert len(empty_results) == 1 - _, empty_dirs, empty_nondirs = empty_results[0] - assert len(empty_dirs) == 0 - assert len(empty_nondirs) == 0 - - def test_walk_recursive_false(self, project_model: Project) -> None: - """Test walk method with recursive=False.""" - # GIVEN: A folder with a hierarchical structure - hierarchy = self.create_test_hierarchy(project_model) - - # WHEN: Walking through the folder with recursive=False - results = list( - hierarchy["folder"].walk(recursive=False, synapse_client=self.syn) - ) - - # THEN: Should get only 1 result (folder root only) - assert len(results) == 1 - - # AND: Folder root should contain direct children only - dirpath, dirs, nondirs = results[0] - assert dirpath[0] == hierarchy["folder"].name - assert dirpath[1] == hierarchy["folder"].id - assert len(dirs) == 2 # nested_folder and empty_folder - assert len(nondirs) == 1 # root_file - - # AND: All returned objects should be EntityHeader instances - assert hasattr(dirs[0], "name") - assert hasattr(dirs[0], "id") - assert hasattr(dirs[0], "type") - assert hasattr(nondirs[0], "name") - assert hasattr(nondirs[0], "id") - assert hasattr(nondirs[0], "type") diff --git a/tests/integration/synapseclient/models/synchronous/test_form.py b/tests/integration/synapseclient/models/synchronous/test_form.py deleted file mode 100644 index da323090f..000000000 --- a/tests/integration/synapseclient/models/synchronous/test_form.py +++ /dev/null @@ -1,233 +0,0 @@ -""" -Integration tests for the synapseclient.models.Form class. -""" -import tempfile -import uuid -from typing import Callable - -import pytest - -import synapseclient.core.utils as utils -from synapseclient import Synapse -from synapseclient.models import File, FormData, FormGroup, Project -from synapseclient.models.mixins.form import StateEnum - - -class TestFormGroup: - def test_create_form_group( - self, syn, schedule_for_cleanup: Callable[..., None] - ) -> None: - """Test creating a form group.""" - unique_name = str(uuid.uuid4()) - form_group = FormGroup(name=unique_name).create_or_get(synapse_client=syn) - - assert form_group is not None - assert form_group.group_id is not None - assert form_group.name == unique_name - - schedule_for_cleanup(form_group.group_id) - - def test_raise_error_on_missing_name(self, syn) -> None: - """Test that creating a form group without a name raises an error.""" - form_group = FormGroup() - - with pytest.raises(ValueError) as e: - form_group.create_or_get(synapse_client=syn) - assert ( - str(e.value) == "FormGroup 'name' must be provided to create a FormGroup." - ) - - -class TestFormData: - @pytest.fixture(autouse=True, scope="session") - def test_form_group( - self, syn: Synapse, schedule_for_cleanup: Callable[..., None] - ) -> FormGroup: - """Create a test form group for use in form data tests.""" - unique_name = "test_form_group_" + str(uuid.uuid4()) - form_group = FormGroup(name=unique_name) - form_group = form_group.create_or_get(synapse_client=syn) - - schedule_for_cleanup(form_group.group_id) - - return form_group - - @pytest.fixture(autouse=True, scope="session") - def test_file( - self, syn: Synapse, schedule_for_cleanup: Callable[..., None] - ) -> File: - """Create a test file for use in form data tests.""" - # Create a test project and a test file to get a file handle ID - project_name = str(uuid.uuid4()) - project = Project(name=project_name) - project = project.store(synapse_client=syn) - - file_path = utils.make_bogus_data_file() - file = File(path=file_path, parent_id=project.id).store(synapse_client=syn) - - schedule_for_cleanup(file.id) - schedule_for_cleanup(file_path) - schedule_for_cleanup(project.id) - - return file - - def test_create_form_data( - self, - syn: Synapse, - test_form_group: FormGroup, - test_file: File, - schedule_for_cleanup: Callable[..., None], - ) -> None: - """Test creating form data.""" - unique_name = "test_form_data_" + str(uuid.uuid4()) - - form_data = FormData( - name=unique_name, - group_id=test_form_group.group_id, - data_file_handle_id=test_file.file_handle.id, - ).create_or_get(synapse_client=syn) - - assert form_data is not None - assert form_data.form_data_id is not None - assert form_data.name == unique_name - assert form_data.group_id == test_form_group.group_id - assert form_data.data_file_handle_id == test_file.file_handle.id - assert form_data.submission_status is not None - assert form_data.submission_status.state.value == "WAITING_FOR_SUBMISSION" - - schedule_for_cleanup(form_data.form_data_id) - - def test_create_raise_error_on_missing_fields(self, syn: Synapse) -> None: - """Test that creating form data without required fields raises an error.""" - form_data = FormData() - - with pytest.raises(ValueError) as e: - form_data.create_or_get(synapse_client=syn) - assert ( - str(e.value) - == "Missing required fields: 'group_id', 'name', and 'data_file_handle_id' are required to create a FormData." - ) - - def test_list_form_data_reviewer_false( - self, - syn: Synapse, - test_form_group: FormGroup, - test_file: File, - schedule_for_cleanup: Callable[..., None], - ) -> None: - """Test listing form data.""" - # Create multiple form data entries - form_data_ids = [] - for i in range(3): - unique_name = f"test_form_data_{i}_" + str(uuid.uuid4()) - form_data = FormData( - name=unique_name, - group_id=test_form_group.group_id, - data_file_handle_id=test_file.file_handle.id, - ).create_or_get(synapse_client=syn) - form_data_ids.append(form_data.form_data_id) - schedule_for_cleanup(form_data.form_data_id) - - # List form data owned by the caller - use FormGroup.list() - retrieved_ids = [] - for form_data in FormGroup(group_id=test_form_group.group_id).list( - synapse_client=syn, - filter_by_state=["waiting_for_submission"], - as_reviewer=False, - ): - retrieved_ids.append(form_data.form_data_id) - - for form_data_id in form_data_ids: - assert form_data_id in retrieved_ids - - def test_list_form_data_reviewer_true( - self, - syn: Synapse, - test_form_group: FormGroup, - test_file: File, - schedule_for_cleanup: Callable[..., None], - ) -> None: - """Test listing form data as reviewer.""" - # Create multiple form data entries - form_data_ids = [] - for i in range(3): - unique_name = f"test_form_data_{i}_" + str(uuid.uuid4()) - form_data = FormData( - name=unique_name, - group_id=test_form_group.group_id, - data_file_handle_id=test_file.file_handle.id, - ).create_or_get(synapse_client=syn) - # Submit the form data - syn.restPOST(uri=f"/form/data/{form_data.form_data_id}/submit", body={}) - form_data_ids.append(form_data.form_data_id) - schedule_for_cleanup(form_data.form_data_id) - - # List form data as reviewer - use FormGroup.list() - retrieved_ids = [] - for form_data in FormGroup(group_id=test_form_group.group_id).list( - synapse_client=syn, - filter_by_state=["submitted_waiting_for_review"], - as_reviewer=True, - ): - retrieved_ids.append(form_data.form_data_id) - - for form_data_id in form_data_ids: - assert form_data_id in retrieved_ids - - def test_download_form_data( - self, - syn: Synapse, - test_form_group: FormGroup, - test_file: File, - schedule_for_cleanup: Callable[..., None], - ) -> None: - """Test downloading form data.""" - unique_name = "test_form_data_" + str(uuid.uuid4()) - - form_data = FormData( - name=unique_name, - group_id=test_form_group.group_id, - data_file_handle_id=test_file.file_handle.id, - ).create_or_get(synapse_client=syn) - - schedule_for_cleanup(form_data.form_data_id) - - # Download using the created form_data instance - downloaded_form_path = form_data.download( - synapse_client=syn, - synapse_id=test_file.id, # Use the File's synapse ID, not form_data_id - ) - - schedule_for_cleanup(downloaded_form_path) - - assert test_file.file_handle.id in downloaded_form_path - - def test_download_form_data_with_directory( - self, - syn: Synapse, - test_form_group: FormGroup, - test_file: File, - schedule_for_cleanup: Callable[..., None], - ) -> None: - """Test downloading form data to a specific directory.""" - unique_name = "test_form_data_" + str(uuid.uuid4()) - - form_data = FormData( - name=unique_name, - group_id=test_form_group.group_id, - data_file_handle_id=test_file.file_handle.id, - ).create_or_get(synapse_client=syn) - - tmp_dir = tempfile.mkdtemp() - schedule_for_cleanup(tmp_dir) - schedule_for_cleanup(form_data.form_data_id) - - # Download using the created form_data instance - downloaded_form_path = form_data.download( - synapse_client=syn, - synapse_id=form_data.form_data_id, - download_location=tmp_dir, - ) - - assert test_file.file_handle.id in downloaded_form_path - assert str(downloaded_form_path).startswith(str(tmp_dir)) diff --git a/tests/integration/synapseclient/models/synchronous/test_grid.py b/tests/integration/synapseclient/models/synchronous/test_grid.py deleted file mode 100644 index bc52f5e83..000000000 --- a/tests/integration/synapseclient/models/synchronous/test_grid.py +++ /dev/null @@ -1,166 +0,0 @@ -"""Integration tests for the synapseclient.models.Grid class.""" - -import os -import tempfile -import uuid -from typing import Callable - -import pandas as pd -import pytest - -from synapseclient import Synapse -from synapseclient.models import Grid, Project, RecordSet - - -class TestGrid: - """Tests for the Grid methods.""" - - @pytest.fixture(autouse=True, scope="function") - def init(self, syn: Synapse, schedule_for_cleanup: Callable[..., None]) -> None: - self.syn = syn - self.schedule_for_cleanup = schedule_for_cleanup - - @pytest.fixture(scope="function") - def record_set_fixture(self, project_model: Project) -> RecordSet: - """Create a RecordSet fixture for Grid testing.""" - # Create test data as a pandas DataFrame - test_data = pd.DataFrame( - { - "id": [1, 2, 3, 4, 5], - "name": ["Alpha", "Beta", "Gamma", "Delta", "Epsilon"], - "value": [10.5, 20.3, 30.7, 40.1, 50.9], - "category": ["A", "B", "A", "C", "B"], - "active": [True, False, True, True, False], - } - ) - - # Create a temporary CSV file - temp_fd, filename = tempfile.mkstemp(suffix=".csv") - try: - os.close(temp_fd) # Close the file descriptor - test_data.to_csv(filename, index=False) - self.schedule_for_cleanup(filename) - - record_set = RecordSet( - path=filename, - name=str(uuid.uuid4()), - description="Test RecordSet for Grid testing", - version_comment="Grid test version", - version_label=str(uuid.uuid4()), - upsert_keys=["id", "name"], - ) - - stored_record_set = record_set.store( - parent=project_model, synapse_client=self.syn - ) - self.schedule_for_cleanup(stored_record_set.id) - return stored_record_set - except Exception: - # Clean up the temp file if something goes wrong - if os.path.exists(filename): - os.unlink(filename) - raise - - def test_create_and_list_grid_sessions(self, record_set_fixture: RecordSet) -> None: - # GIVEN: A Grid instance with a record_set_id - grid = Grid(record_set_id=record_set_fixture.id) - - # WHEN: Creating a grid session - created_grid = grid.create(synapse_client=self.syn) - - # THEN: The grid should be created successfully - assert created_grid is grid # Should return the same instance - assert created_grid.session_id is not None - assert created_grid.started_by is not None - assert created_grid.started_on is not None - assert created_grid.etag is not None - assert created_grid.source_entity_id == record_set_fixture.id - - # WHEN: Listing grid sessions - sessions = list( - Grid.list(source_id=record_set_fixture.id, synapse_client=self.syn) - ) - - # THEN: The created session should appear in the list - assert len(sessions) >= 1 - session_ids = [session.session_id for session in sessions] - assert created_grid.session_id in session_ids - - # Find our specific session - our_session = next( - session - for session in sessions - if session.session_id == created_grid.session_id - ) - assert our_session.started_by == created_grid.started_by - assert our_session.source_entity_id == record_set_fixture.id - - def test_create_grid_session_and_reuse_session( - self, record_set_fixture: RecordSet - ) -> None: - # GIVEN: Create the first Grid instance with a record_set_id - grid1 = Grid(record_set_id=record_set_fixture.id) - - # WHEN: Creating the first grid session - created_grid1 = grid1.create(synapse_client=self.syn) - - # THEN: A session should be created - assert created_grid1.session_id is not None - first_session_id = created_grid1.session_id - - # GIVEN: Create a second Grid instance with the same record_set_id - grid2 = Grid(record_set_id=record_set_fixture.id) - - # WHEN: Creating a second grid session (should reuse the existing one) - created_grid2 = grid2.create( - synapse_client=self.syn, attach_to_previous_session=True - ) - - # THEN: The same session should be reused - assert created_grid2.session_id == first_session_id - assert created_grid2.started_by == created_grid1.started_by - assert created_grid2.started_on == created_grid1.started_on - assert created_grid2.source_entity_id == record_set_fixture.id - - def test_create_grid_session_validation_error(self) -> None: - # GIVEN: A Grid instance with no record_set_id or initial_query - grid = Grid() - - # WHEN/THEN: Creating a grid session should raise ValueError - with pytest.raises( - ValueError, - match="record_set_id or initial_query is required to create a GridSession", - ): - grid.create(synapse_client=self.syn) - - def test_delete_grid_session(self, record_set_fixture: RecordSet) -> None: - # GIVEN: Create a grid session first - grid = Grid(record_set_id=record_set_fixture.id) - created_grid = grid.create(synapse_client=self.syn) - - # Ensure we have a session_id - assert created_grid.session_id is not None - session_id = created_grid.session_id - - # WHEN: Deleting the grid session - created_grid.delete(synapse_client=self.syn) - - # THEN: The session should no longer exist in the list - sessions = list( - Grid.list(source_id=record_set_fixture.id, synapse_client=self.syn) - ) - - # The deleted session should not appear in the list - session_ids = [session.session_id for session in sessions] - assert session_id not in session_ids - - def test_delete_grid_session_validation_error(self) -> None: - # GIVEN: A Grid instance with no session_id - grid = Grid() - - # WHEN/THEN: Deleting a grid session should raise ValueError - with pytest.raises( - ValueError, - match="session_id is required to delete a GridSession", - ): - grid.delete(synapse_client=self.syn) diff --git a/tests/integration/synapseclient/models/synchronous/test_json_schema.py b/tests/integration/synapseclient/models/synchronous/test_json_schema.py deleted file mode 100644 index 20f2be6e6..000000000 --- a/tests/integration/synapseclient/models/synchronous/test_json_schema.py +++ /dev/null @@ -1,576 +0,0 @@ -import random -import time -import uuid -from typing import Callable, Generator, Optional, Tuple, Type, Union - -import pytest - -from synapseclient import Synapse -from synapseclient.core import utils -from synapseclient.core.exceptions import SynapseHTTPError -from synapseclient.models import ( - Column, - ColumnType, - EntityView, - File, - Folder, - Project, - Table, - ViewTypeMask, -) -from synapseclient.services.json_schema import JsonSchemaOrganization - -DESCRIPTION_FOLDER = "This is a folder for testing JSON schema functionality." -DESCRIPTION_FILE = "This is an example file." -CONTENT_TYPE_FILE = "text/plain" -VERSION_COMMENT = "My version comment" - -TEST_SCHEMA_NAME = "example.dpetest.jsonschema" -SCHEMA_VERSION = "0.0.1" - - -class TestJSONSchema: - @pytest.fixture(autouse=True, scope="function") - def init(self, syn: Synapse, schedule_for_cleanup: Callable[..., None]) -> None: - self.syn = syn - self.schedule_for_cleanup = schedule_for_cleanup - - def create_entity( - self, - entity_type: Type[Union[Project, Folder, File, Table, EntityView]], - project_model: Optional[Project] = None, - file_fixture: Optional[File] = None, - table_fixture: Optional[Table] = None, - entity_view_fixture: Optional[EntityView] = None, - name_suffix: str = "", - ) -> Union[Project, Folder, File, Table, EntityView]: - """Helper to create different entity types with consistent naming""" - entity_name = str(uuid.uuid4()) + name_suffix - - if entity_type == Project: - entity = Project(name=entity_name).store(synapse_client=self.syn) - elif entity_type == Folder: - folder = Folder(name=entity_name) - entity = folder.store(parent=project_model, synapse_client=self.syn) - elif entity_type == File: - file_fixture.name = entity_name - entity = file_fixture.store(parent=project_model, synapse_client=self.syn) - elif entity_type == Table: - table_fixture.name = entity_name - entity = table_fixture.store(synapse_client=self.syn) - elif entity_type == EntityView: - entity = entity_view_fixture.store(synapse_client=self.syn) - else: - raise ValueError(f"Unsupported entity type: {entity_type}") - - self.schedule_for_cleanup(entity.id) - return entity - - @pytest.fixture(scope="function") - def create_test_organization_with_schema( - self, syn: Synapse - ) -> Generator[Tuple[JsonSchemaOrganization, str], None, None]: - """Create a test organization for JSON schema functionality.""" - org_name = "dpetest" + uuid.uuid4().hex[:6] - - js = syn.service("json_schema") - created_org = js.create_organization(org_name) - - # Add a JSON schema - try: - schema = { - "$schema": "http://json-schema.org/draft-07/schema#", - "$id": "https://example.com/schema/productschema.json", - "title": "Product Schema", - "type": "object", - "properties": { - "productId": { - "description": "The unique identifier for a product", - "type": "integer", - "const": 123, - }, - "productName": { - "description": "Name of the product", - "type": "string", - "const": "default product name", - }, - "productDescription": { - "description": "description of the product", - "type": "string", - }, - "productQuantity": { - "description": "quantity of the product", - "type": "integer", - }, - }, - } - test_org = js.JsonSchemaOrganization(org_name) - created_schema = test_org.create_json_schema( - schema, TEST_SCHEMA_NAME, "0.0.1" - ) - yield test_org, created_schema.uri - finally: - js.delete_json_schema(created_schema.uri) - js.delete_organization(created_org["id"]) - - @pytest.fixture(scope="function") - def file(self) -> File: - filename = utils.make_bogus_uuid_file() - return File(path=filename) - - @pytest.fixture(scope="function") - def table(self, project_model: Project) -> Table: - columns = [ - Column(id=None, name="my_string_col", column_type=ColumnType.STRING), - ] - table = Table( - columns=columns, - parent_id=project_model.id, - ) - return table - - @pytest.fixture(scope="function") - def entity_view(self, project_model: Project) -> EntityView: - entity_view = EntityView( - parent_id=project_model.id, - scope_ids=[project_model.id], - view_type_mask=ViewTypeMask.FILE | ViewTypeMask.FOLDER, - ) - return entity_view - - @pytest.mark.parametrize("entity_type", [Folder, Project, File, EntityView, Table]) - def test_bind_schema( - self, - entity_type: Type[Union[Folder, Project, File, EntityView, Table]], - project_model: Project, - file: File, - table: Table, - entity_view: EntityView, - create_test_organization_with_schema: Tuple[JsonSchemaOrganization, str], - ) -> None: - """Test binding a JSON schema to a folder entity.""" - created_entity = self.create_entity( - entity_type, - project_model, - file_fixture=file, - table_fixture=table, - entity_view_fixture=entity_view, - name_suffix="_test_json_schema_bind", - ) - - # Bind the JSON schema to the entity - test_org, test_product_schema_uri = create_test_organization_with_schema - try: - response = created_entity.bind_schema( - json_schema_uri=test_product_schema_uri, synapse_client=self.syn - ) - json_schema_version_info = response.json_schema_version_info - assert json_schema_version_info.organization_name == test_org.name - assert json_schema_version_info.id == test_product_schema_uri - finally: - # Clean up the JSON schema binding - created_entity.unbind_schema(synapse_client=self.syn) - - @pytest.mark.parametrize("entity_type", [Folder, Project, File, EntityView, Table]) - def test_get_schema( - self, - entity_type: Type[Union[Folder, Project, File, EntityView, Table]], - project_model: Project, - file: File, - table: Table, - entity_view: EntityView, - create_test_organization_with_schema: Tuple[JsonSchemaOrganization, str], - ) -> None: - """Test retrieving a bound JSON schema from an entity.""" - created_entity = self.create_entity( - entity_type, - project_model, - file_fixture=file, - table_fixture=table, - entity_view_fixture=entity_view, - name_suffix="_test_json_schema_get", - ) - - # Bind the JSON schema to the folder - try: - test_org, test_product_schema_uri = create_test_organization_with_schema - created_entity.bind_schema( - json_schema_uri=test_product_schema_uri, synapse_client=self.syn - ) - - # Retrieve the JSON schema from the folder - response = created_entity.get_schema(synapse_client=self.syn) - assert response.json_schema_version_info.organization_name == test_org.name - assert response.json_schema_version_info.id == test_product_schema_uri - finally: - # Clean up the JSON schema binding - created_entity.unbind_schema(synapse_client=self.syn) - - @pytest.mark.parametrize("entity_type", [Folder, Project, File, EntityView, Table]) - def test_unbind_schema( - self, - entity_type: Type[Union[Folder, Project, File, EntityView, Table]], - project_model: Project, - file: File, - table: Table, - entity_view: EntityView, - create_test_organization_with_schema: Tuple[JsonSchemaOrganization, str], - ) -> None: - """Test unbinding a bound JSON schema from an entity.""" - # Create the folder - created_entity = self.create_entity( - entity_type, - project_model, - file_fixture=file, - table_fixture=table, - entity_view_fixture=entity_view, - name_suffix="_test_json_schema_delete", - ) - - # Bind the JSON schema to the folder - _, test_product_schema_uri = create_test_organization_with_schema - created_entity.bind_schema( - json_schema_uri=test_product_schema_uri, synapse_client=self.syn - ) - - # Unbind the JSON schema from the folder - created_entity.unbind_schema(synapse_client=self.syn) - - # Verify that the JSON schema is no longer bound - with pytest.raises( - SynapseHTTPError, - match=f"404 Client Error: No JSON schema found for '{created_entity.id}'", - ): - created_entity.get_schema(synapse_client=self.syn) - - @pytest.mark.parametrize("entity_type", [Folder, Project, File, EntityView, Table]) - def test_get_schema_derived_keys( - self, - entity_type: Type[Union[Folder, Project, File, EntityView, Table]], - project_model: Project, - file: File, - table: Table, - entity_view: EntityView, - create_test_organization_with_schema: Tuple[ - JsonSchemaOrganization, - str, - ], - ) -> None: - """Test retrieving derived keys from a bound JSON schema.""" - created_entity = self.create_entity( - entity_type, - project_model, - file_fixture=file, - table_fixture=table, - entity_view_fixture=entity_view, - name_suffix="_test_json_schema_derived_keys", - ) - _, test_product_schema_uri = create_test_organization_with_schema - - # Bind the JSON schema to the entity - try: - created_entity.bind_schema( - json_schema_uri=test_product_schema_uri, - enable_derived_annotations=True, - synapse_client=self.syn, - ) - - # Store annotations - created_entity.annotations = { - "productDescription": "test description here", - "productQuantity": 100, - "productPrice": 100, - } - - created_entity.store(synapse_client=self.syn) - - response = created_entity.get_schema(synapse_client=self.syn) - assert response.enable_derived_annotations == True - - time.sleep(2) - - # Retrieve the derived keys from the folder - response = created_entity.get_schema_derived_keys(synapse_client=self.syn) - - assert set(response.keys) == {"productId", "productName"} - finally: - # Clean up the JSON schema binding - created_entity.unbind_schema(synapse_client=self.syn) - - @pytest.mark.parametrize("entity_type", [Folder, Project, File, EntityView, Table]) - def test_validate_schema_invalid_annos( - self, - entity_type: Type[Union[Folder, Project, File, EntityView, Table]], - project_model: Project, - file: File, - table: Table, - entity_view: EntityView, - create_test_organization_with_schema: Tuple[JsonSchemaOrganization, str], - ) -> None: - """Test validating invalid annotations against a bound JSON schema.""" - created_entity = self.create_entity( - entity_type, - project_model, - file_fixture=file, - table_fixture=table, - entity_view_fixture=entity_view, - name_suffix="_test_json_schema_invalid_annos", - ) - - _, test_product_schema_uri = create_test_organization_with_schema - - # Bind the JSON schema to the entity - try: - created_entity.bind_schema( - json_schema_uri=test_product_schema_uri, - synapse_client=self.syn, - ) - - # Store annotations that do not match the schema - created_entity.annotations = { - "productDescription": 1000, - "productQuantity": "invalid string", - } - created_entity.store(synapse_client=self.syn) - # Ensure annotations are stored - time.sleep(2) - - # Validate the folder against the JSON schema - response = created_entity.validate_schema(synapse_client=self.syn) - assert response.validation_response.is_valid == False - assert response.validation_response.id is not None - - all_messages = response.all_validation_messages - - assert ( - "#/productQuantity: expected type: Integer, found: String" - in all_messages - ) - assert ( - "#/productDescription: expected type: String, found: Long" - in all_messages - ) - finally: - # Clean up the JSON schema binding - created_entity.unbind_schema(synapse_client=self.syn) - - @pytest.mark.parametrize("entity_type", [Folder, Project, File, EntityView, Table]) - def test_validate_schema_valid_annos( - self, - entity_type: Type[Union[Folder, Project, File, EntityView, Table]], - project_model: Project, - file: File, - table: Table, - entity_view: EntityView, - create_test_organization_with_schema: Tuple[JsonSchemaOrganization, str], - ) -> None: - """Test validating valid annotations against a bound JSON schema.""" - created_entity = self.create_entity( - entity_type, - project_model, - file_fixture=file, - table_fixture=table, - entity_view_fixture=entity_view, - name_suffix="_test_json_schema_valid_annos", - ) - _, test_product_schema_uri = create_test_organization_with_schema - - # Bind the JSON schema to the folder - try: - created_entity.bind_schema( - json_schema_uri=test_product_schema_uri, - synapse_client=self.syn, - ) - - # Store annotations that match the schema - created_entity.annotations = { - "productDescription": "This is a test product.", - "productQuantity": 100, - } - created_entity.store(synapse_client=self.syn) - # Ensure annotations are stored - time.sleep(2) - response = created_entity.validate_schema(synapse_client=self.syn) - assert response.is_valid == True - finally: - # Clean up the JSON schema binding - created_entity.unbind_schema(synapse_client=self.syn) - - @pytest.mark.parametrize("entity_type", [Folder, Project]) - def test_get_validation_statistics( - self, - entity_type: Type[Union[Folder, Project, File, EntityView, Table]], - project_model: Project, - create_test_organization_with_schema: Tuple[JsonSchemaOrganization, str], - ) -> None: - """Test retrieving JSON schema validation statistics for a folder.""" - # Create the folder - created_entity = self.create_entity( - entity_type, - project_model, - name_suffix="_test_json_schema_validation_statistics", - ) - - # Create two files under the folder - filename = utils.make_bogus_uuid_file() - self.schedule_for_cleanup(filename) - file_1 = File( - path=filename, - name="test_file_1", - description=DESCRIPTION_FILE, - content_type=CONTENT_TYPE_FILE, - version_comment=VERSION_COMMENT, - version_label=str(uuid.uuid4()), - parent_id=created_entity.id, - ).store(synapse_client=self.syn) - self.schedule_for_cleanup(file_1.id) - - filename = utils.make_bogus_uuid_file() - self.schedule_for_cleanup(filename) - file_2 = File( - path=filename, - name="test_file_2", - description=DESCRIPTION_FILE, - content_type=CONTENT_TYPE_FILE, - version_comment=VERSION_COMMENT, - version_label=str(uuid.uuid4()), - parent_id=created_entity.id, - ).store(synapse_client=self.syn) - self.schedule_for_cleanup(file_2.id) - - _, test_product_schema_uri = create_test_organization_with_schema - - # Bind the JSON SCHEMA to the folder - try: - created_entity.bind_schema( - json_schema_uri=test_product_schema_uri, - synapse_client=self.syn, - ) - - # Store annotations in the file, one valid and one invalid - file_1.annotations = { - "productDescription": "test description here.", - "productQuantity": 100, - } - file_2.annotations = { - "productDescription": 200, - "productQuantity": "invalid string", - } - - file_1.store(parent=created_entity, synapse_client=self.syn) - file_2.store(parent=created_entity, synapse_client=self.syn) - # Ensure annotations are stored - time.sleep(2) - - # validate the entity against the JSON SCHEMA - created_entity.validate_schema(synapse_client=self.syn) - - # Get validation statistics of the entity - response = created_entity.get_schema_validation_statistics( - synapse_client=self.syn - ) - assert response.number_of_valid_children == 1 - assert response.number_of_invalid_children == 1 - finally: - # Clean up the JSON schema binding - created_entity.unbind_schema(synapse_client=self.syn) - - @pytest.mark.parametrize("entity_type", [Folder, Project]) - def test_get_invalid_validation( - self, - entity_type: Type[Union[Folder, Project]], - project_model: Project, - create_test_organization_with_schema: Tuple[JsonSchemaOrganization, str], - ) -> None: - """Test retrieving invalid JSON schema validation results for a folder.""" - created_entity = self.create_entity( - entity_type, project_model, name_suffix="_test_invalid_json_schema" - ) - test_org, test_product_schema_uri = create_test_organization_with_schema - - # Create two files under the folder - filename = utils.make_bogus_uuid_file() - self.schedule_for_cleanup(filename) - file_1 = File( - path=filename, - name="test_file_1", - description=DESCRIPTION_FILE, - content_type=CONTENT_TYPE_FILE, - version_comment=VERSION_COMMENT, - version_label=str(uuid.uuid4()), - parent_id=created_entity.id, - ).store(synapse_client=self.syn) - self.schedule_for_cleanup(file_1.id) - - filename = utils.make_bogus_uuid_file() - self.schedule_for_cleanup(filename) - file_2 = File( - path=filename, - name="test_file_2", - description=DESCRIPTION_FILE, - content_type=CONTENT_TYPE_FILE, - version_comment=VERSION_COMMENT, - version_label=str(uuid.uuid4()), - parent_id=created_entity.id, - ).store(synapse_client=self.syn) - self.schedule_for_cleanup(file_2.id) - - # Bind the JSON SCHEMA to the folder - try: - created_entity.bind_schema( - json_schema_uri=test_product_schema_uri, - synapse_client=self.syn, - ) - - # Store annotations in the file, one valid and one invalid - file_1.annotations = { - "productDescription": "test description here.", - "productQuantity": 100, - } - file_2.annotations = { - "productDescription": 200, - "productQuantity": "invalid string", - } - - file_1.store(parent=created_entity, synapse_client=self.syn) - file_2.store(parent=created_entity, synapse_client=self.syn) - # Ensure annotations are stored - time.sleep(2) - - # Get invalid validation results of the folder - # The generator `gen` yields validation results for entities that failed JSON schema validation. - # Each item in `gen` is expected to be a dictionary containing details about the validation failure. - gen = created_entity.get_invalid_validation(synapse_client=self.syn) - for item in gen: - validation_response = item.validation_response - validation_error_message = item.validation_error_message - validation_exception = item.validation_exception - causing_exceptions = validation_exception.causing_exceptions - - assert validation_response.object_type == "entity" - assert validation_response.object_etag is not None - assert validation_response.id.endswith( - f"repo/v1/schema/type/registered/{test_org.name}-{TEST_SCHEMA_NAME}-{SCHEMA_VERSION}" - ) - assert validation_response.is_valid == False - assert validation_exception.message == "2 schema violations found" - - assert validation_error_message == "2 schema violations found" - # Assert the number of violations - assert len(causing_exceptions) == 2 - - # Assert both expected violations are present - assert any( - exc.pointer_to_violation == "#/productQuantity" - and exc.message == "expected type: Integer, found: String" - for exc in causing_exceptions - ) - - assert any( - exc.pointer_to_violation == "#/productDescription" - and exc.message == "expected type: String, found: Long" - for exc in causing_exceptions - ) - finally: - # Clean up the JSON schema binding - created_entity.unbind_schema(synapse_client=self.syn) diff --git a/tests/integration/synapseclient/models/synchronous/test_materializedview.py b/tests/integration/synapseclient/models/synchronous/test_materializedview.py deleted file mode 100644 index 39bd2f64b..000000000 --- a/tests/integration/synapseclient/models/synchronous/test_materializedview.py +++ /dev/null @@ -1,620 +0,0 @@ -import time -import uuid -from typing import Callable - -import pandas as pd -import pytest - -from synapseclient import Synapse -from synapseclient.core.exceptions import SynapseHTTPError -from synapseclient.models import Column, ColumnType, MaterializedView, Project, Table - - -class TestMaterializedViewBasics: - @pytest.fixture(autouse=True, scope="function") - def init(self, syn: Synapse, schedule_for_cleanup: Callable[..., None]) -> None: - self.syn = syn - self.schedule_for_cleanup = schedule_for_cleanup - - def test_empty_defining_sql_validation(self, project_model: Project) -> None: - # GIVEN a MaterializedView with an empty defining SQL - materialized_view = MaterializedView( - name=str(uuid.uuid4()), - description="Test materialized view", - parent_id=project_model.id, - defining_sql="", - ) - - # WHEN storing the materialized view - # THEN a 400 Client Error should be raised - with pytest.raises(SynapseHTTPError) as e: - materialized_view.store(synapse_client=self.syn) - - assert ( - "400 Client Error: The definingSQL of the materialized view is required " - "and must not be the empty string." in str(e.value) - ) - - def test_table_without_columns_validation(self, project_model: Project) -> None: - # GIVEN a table with no columns - table_name = str(uuid.uuid4()) - table = Table( - name=table_name, - parent_id=project_model.id, - ) - table = table.store(synapse_client=self.syn) - self.schedule_for_cleanup(table.id) - - # AND a materialized view that uses the table in its defining SQL - materialized_view_name = str(uuid.uuid4()) - materialized_view = MaterializedView( - name=materialized_view_name, - parent_id=project_model.id, - description="Test materialized view", - defining_sql=f"SELECT * FROM {table.id}", - ) - - # WHEN trying to store the materialized view - # THEN a 400 Client Error should be raised - with pytest.raises(SynapseHTTPError) as e: - materialized_view.store(synapse_client=self.syn) - - assert f"400 Client Error: Schema for {table.id} is empty." in str(e.value) - - def test_invalid_sql_validation(self, project_model: Project) -> None: - # GIVEN a materialized view with invalid SQL - materialized_view = MaterializedView( - name=str(uuid.uuid4()), - parent_id=project_model.id, - description="Test materialized view", - defining_sql="INVALID SQL", - ) - - # WHEN storing the materialized view - # THEN a 400 Client Error should be raised - with pytest.raises(SynapseHTTPError) as e: - materialized_view.store(synapse_client=self.syn) - - assert ( - '400 Client Error: Encountered " "INVALID "" ' - "at line 1, column 1.\nWas expecting one of:" in str(e.value) - ) - - def test_create_and_retrieve_materialized_view( - self, project_model: Project - ) -> None: - # GIVEN a table with columns - table_name = str(uuid.uuid4()) - table = Table( - name=table_name, - parent_id=project_model.id, - columns=[ - Column(name="test_column", column_type=ColumnType.STRING), - Column(name="test_column2", column_type=ColumnType.INTEGER), - ], - ) - table = table.store(synapse_client=self.syn) - self.schedule_for_cleanup(table.id) - - # WHEN creating a materialized view - materialized_view_name = str(uuid.uuid4()) - materialized_view_description = "Test materialized view" - materialized_view = MaterializedView( - name=materialized_view_name, - parent_id=project_model.id, - description=materialized_view_description, - defining_sql=f"SELECT * FROM {table.id}", - ) - materialized_view = materialized_view.store(synapse_client=self.syn) - self.schedule_for_cleanup(materialized_view.id) - - # THEN the materialized view should be created - assert materialized_view.id is not None - - # AND when retrieving the materialized view - new_materialized_view = MaterializedView(id=materialized_view.id).get( - synapse_client=self.syn - ) - - # THEN it should have the expected properties - assert new_materialized_view is not None - assert new_materialized_view.name == materialized_view_name - assert new_materialized_view.id == materialized_view.id - assert new_materialized_view.description == materialized_view_description - - def test_update_materialized_view(self, project_model: Project) -> None: - # GIVEN a table with columns - table_name = str(uuid.uuid4()) - table = Table( - name=table_name, - parent_id=project_model.id, - columns=[ - Column(name="test_column", column_type=ColumnType.STRING), - Column(name="test_column2", column_type=ColumnType.INTEGER), - ], - ) - table = table.store(synapse_client=self.syn) - self.schedule_for_cleanup(table.id) - - # AND a materialized view - materialized_view = MaterializedView( - name=str(uuid.uuid4()), - parent_id=project_model.id, - description="Original description", - defining_sql=f"SELECT * FROM {table.id}", - ) - materialized_view = materialized_view.store(synapse_client=self.syn) - self.schedule_for_cleanup(materialized_view.id) - - # WHEN updating the materialized view properties - new_name = str(uuid.uuid4()) - new_description = "Updated description" - new_sql = f"SELECT test_column FROM {table.id}" - - materialized_view.name = new_name - materialized_view.description = new_description - materialized_view.defining_sql = new_sql - - updated_materialized_view = materialized_view.store(synapse_client=self.syn) - - # THEN the updated properties should be reflected when retrieved - retrieved_materialized_view = MaterializedView(id=materialized_view.id).get( - synapse_client=self.syn - ) - - assert retrieved_materialized_view.name == new_name - assert retrieved_materialized_view.description == new_description - assert retrieved_materialized_view.defining_sql == new_sql - - def test_delete_materialized_view(self, project_model: Project) -> None: - # GIVEN a table with columns - table_name = str(uuid.uuid4()) - table = Table( - name=table_name, - parent_id=project_model.id, - columns=[ - Column(name="test_column", column_type=ColumnType.STRING), - ], - ) - table = table.store(synapse_client=self.syn) - self.schedule_for_cleanup(table.id) - - # AND a materialized view - materialized_view = MaterializedView( - name=str(uuid.uuid4()), - parent_id=project_model.id, - defining_sql=f"SELECT * FROM {table.id}", - ) - materialized_view = materialized_view.store(synapse_client=self.syn) - self.schedule_for_cleanup(materialized_view.id) - - # WHEN deleting the materialized view - materialized_view.delete(synapse_client=self.syn) - - # THEN the materialized view should not be retrievable - with pytest.raises( - SynapseHTTPError, - match=(f"404 Client Error: Entity {materialized_view.id} is in trash can."), - ): - MaterializedView(id=materialized_view.id).get(synapse_client=self.syn) - - -class TestMaterializedViewWithData: - @pytest.fixture(autouse=True, scope="function") - def init(self, syn: Synapse, schedule_for_cleanup: Callable[..., None]) -> None: - self.syn = syn - self.schedule_for_cleanup = schedule_for_cleanup - - def setup_table_with_data(self, project_model: Project) -> Table: - """Helper method to create a table with data for testing""" - table_name = str(uuid.uuid4()) - table = Table( - name=table_name, - parent_id=project_model.id, - columns=[ - Column(name="name", column_type=ColumnType.STRING), - Column(name="age", column_type=ColumnType.INTEGER), - ], - ) - table = table.store(synapse_client=self.syn) - self.schedule_for_cleanup(table.id) - - # Insert data into the table - data = pd.DataFrame({"name": ["Alice", "Bob"], "age": [30, 25]}) - table.store_rows(data, synapse_client=self.syn) - - return table - - def test_query_materialized_view(self, project_model: Project) -> None: - # GIVEN a table with data - table = self.setup_table_with_data(project_model) - - # AND a materialized view based on the table - materialized_view = MaterializedView( - name=str(uuid.uuid4()), - parent_id=project_model.id, - defining_sql=f"SELECT * FROM {table.id}", - ) - materialized_view = materialized_view.store(synapse_client=self.syn) - self.schedule_for_cleanup(materialized_view.id) - - # WHEN querying the materialized view - query_result = materialized_view.query( - f"SELECT * FROM {materialized_view.id}", synapse_client=self.syn - ) - - # THEN the query results should match the table data - assert len(query_result) == 2 - assert query_result["name"].tolist() == ["Alice", "Bob"] - assert query_result["age"].tolist() == [30, 25] - - def test_update_defining_sql(self, project_model: Project) -> None: - # GIVEN a table with data - table = self.setup_table_with_data(project_model) - - # AND a materialized view based on the table - materialized_view = MaterializedView( - name=str(uuid.uuid4()), - parent_id=project_model.id, - defining_sql=f"SELECT * FROM {table.id}", - ) - materialized_view = materialized_view.store(synapse_client=self.syn) - self.schedule_for_cleanup(materialized_view.id) - - # WHEN updating the defining SQL - materialized_view.defining_sql = f"SELECT name FROM {table.id}" - materialized_view = materialized_view.store(synapse_client=self.syn) - - # AND querying the materialized view (with delay for eventual consistency) - time.sleep(5) - query_result = materialized_view.query( - f"SELECT * FROM {materialized_view.id}", synapse_client=self.syn - ) - - # THEN the query results should reflect the updated SQL - assert len(query_result) == 2 - assert query_result["name"].tolist() == ["Alice", "Bob"] - assert "age" not in query_result.columns - - # AND the column structure should be updated in the view metadata - retrieved_view = MaterializedView(id=materialized_view.id).get( - synapse_client=self.syn - ) - assert "age" not in retrieved_view.columns.keys() - assert "name" in retrieved_view.columns.keys() - - def test_materialized_view_reflects_table_updates( - self, project_model: Project - ) -> None: - # GIVEN a table with columns but no data - table_name = str(uuid.uuid4()) - table = Table( - name=table_name, - parent_id=project_model.id, - columns=[ - Column(name="name", column_type=ColumnType.STRING), - Column(name="age", column_type=ColumnType.INTEGER), - ], - ) - table = table.store(synapse_client=self.syn) - self.schedule_for_cleanup(table.id) - - # AND a materialized view based on the table - materialized_view = MaterializedView( - name=str(uuid.uuid4()), - parent_id=project_model.id, - defining_sql=f"SELECT * FROM {table.id}", - ) - materialized_view = materialized_view.store(synapse_client=self.syn) - self.schedule_for_cleanup(materialized_view.id) - - # WHEN querying before adding data - query_result = materialized_view.query( - f"SELECT * FROM {materialized_view.id}", synapse_client=self.syn - ) - - # THEN no data should be returned - assert len(query_result) == 0 - - # WHEN adding data to the table - data = pd.DataFrame({"name": ["Alice", "Bob"], "age": [30, 25]}) - table.store_rows(data, synapse_client=self.syn) - - # AND querying again (with delay for eventual consistency) - time.sleep(5) - query_result = materialized_view.query( - f"SELECT * FROM {materialized_view.id}", synapse_client=self.syn - ) - time.sleep(5) - query_result = materialized_view.query( - f"SELECT * FROM {materialized_view.id}", synapse_client=self.syn - ) - - # THEN the query results should reflect the added data - assert len(query_result) == 2 - assert query_result["name"].tolist() == ["Alice", "Bob"] - assert query_result["age"].tolist() == [30, 25] - - def test_materialized_view_reflects_table_data_removal( - self, project_model: Project - ) -> None: - # GIVEN a table with data - table = self.setup_table_with_data(project_model) - - # AND a materialized view based on the table - materialized_view = MaterializedView( - name=str(uuid.uuid4()), - parent_id=project_model.id, - defining_sql=f"SELECT * FROM {table.id}", - ) - materialized_view = materialized_view.store(synapse_client=self.syn) - self.schedule_for_cleanup(materialized_view.id) - - # WHEN removing data from the table - table.delete_rows( - query=f"SELECT ROW_ID, ROW_VERSION FROM {table.id}", synapse_client=self.syn - ) - - # AND querying the materialized view (with delay for eventual consistency) - time.sleep(5) - query_result = materialized_view.query( - f"SELECT * FROM {materialized_view.id}", synapse_client=self.syn - ) - query_result = materialized_view.query( - f"SELECT * FROM {materialized_view.id}", synapse_client=self.syn - ) - - # THEN the query results should reflect the removed data - assert len(query_result) == 0 - - def test_query_part_mask(self, project_model: Project) -> None: - # GIVEN a table with data - table = self.setup_table_with_data(project_model) - - # AND a materialized view based on the table - materialized_view = MaterializedView( - name=str(uuid.uuid4()), - parent_id=project_model.id, - defining_sql=f"SELECT * FROM {table.id}", - ) - materialized_view = materialized_view.store(synapse_client=self.syn) - self.schedule_for_cleanup(materialized_view.id) - - # WHEN querying with part_mask - QUERY_RESULTS = 0x1 - QUERY_COUNT = 0x2 - LAST_UPDATED_ON = 0x80 - part_mask = QUERY_RESULTS | QUERY_COUNT | LAST_UPDATED_ON - - query_result = materialized_view.query_part_mask( - query=f"SELECT * FROM {materialized_view.id}", - part_mask=part_mask, - synapse_client=self.syn, - ) - - # THEN the result should contain the specified parts - assert query_result is not None - assert len(query_result.result) == 2 - assert query_result.result["name"].tolist() == ["Alice", "Bob"] - assert query_result.result["age"].tolist() == [30, 25] - assert query_result.count == 2 - assert query_result.last_updated_on is not None - - def test_materialized_view_with_left_join(self, project_model: Project) -> None: - # GIVEN two tables with related data - table1 = Table( - name=str(uuid.uuid4()), - parent_id=project_model.id, - columns=[ - Column(name="unique_identifier", column_type=ColumnType.INTEGER), - Column(name="name", column_type=ColumnType.STRING), - ], - ) - table1 = table1.store(synapse_client=self.syn) - self.schedule_for_cleanup(table1.id) - - table2 = Table( - name=str(uuid.uuid4()), - parent_id=project_model.id, - columns=[ - Column(name="unique_identifier", column_type=ColumnType.INTEGER), - Column(name="age", column_type=ColumnType.INTEGER), - ], - ) - table2 = table2.store(synapse_client=self.syn) - self.schedule_for_cleanup(table2.id) - - data1 = pd.DataFrame({"unique_identifier": [1, 2], "name": ["Alice", "Bob"]}) - table1.store_rows(data1, synapse_client=self.syn) - - data2 = pd.DataFrame({"unique_identifier": [1, 3], "age": [30, 40]}) - table2.store_rows(data2, synapse_client=self.syn) - - # WHEN creating a materialized view with a LEFT JOIN - left_join_view = MaterializedView( - name=str(uuid.uuid4()), - parent_id=project_model.id, - defining_sql=( - f"SELECT t1.unique_identifier as unique_identifier, t1.name as name, " - f"t2.age as age FROM {table1.id} t1 LEFT JOIN {table2.id} t2 " - f"ON t1.unique_identifier = t2.unique_identifier" - ), - ) - left_join_view = left_join_view.store(synapse_client=self.syn) - self.schedule_for_cleanup(left_join_view.id) - - # AND querying the view - result = left_join_view.query( - f"SELECT * FROM {left_join_view.id}", synapse_client=self.syn - ) - - # THEN the results should match the expected LEFT JOIN result - assert len(result) == 2 - assert result["unique_identifier"].tolist() == [1, 2] - assert result["name"].tolist() == ["Alice", "Bob"] - assert result["age"][0] == 30 - assert pd.isna(result["age"][1]) - - def test_materialized_view_with_right_join(self, project_model: Project) -> None: - # GIVEN two tables with related data - table1 = Table( - name=str(uuid.uuid4()), - parent_id=project_model.id, - columns=[ - Column(name="unique_identifier", column_type=ColumnType.INTEGER), - Column(name="name", column_type=ColumnType.STRING), - ], - ) - table1 = table1.store(synapse_client=self.syn) - self.schedule_for_cleanup(table1.id) - - table2 = Table( - name=str(uuid.uuid4()), - parent_id=project_model.id, - columns=[ - Column(name="unique_identifier", column_type=ColumnType.INTEGER), - Column(name="age", column_type=ColumnType.INTEGER), - ], - ) - table2 = table2.store(synapse_client=self.syn) - self.schedule_for_cleanup(table2.id) - - data1 = pd.DataFrame({"unique_identifier": [1, 2], "name": ["Alice", "Bob"]}) - table1.store_rows(data1, synapse_client=self.syn) - - data2 = pd.DataFrame({"unique_identifier": [1, 3], "age": [30, 40]}) - table2.store_rows(data2, synapse_client=self.syn) - - # WHEN creating a materialized view with a RIGHT JOIN - right_join_view = MaterializedView( - name=str(uuid.uuid4()), - parent_id=project_model.id, - defining_sql=( - f"SELECT t2.unique_identifier as unique_identifier, t1.name as name, " - f"t2.age as age FROM {table1.id} t1 RIGHT JOIN {table2.id} t2 " - f"ON t1.unique_identifier = t2.unique_identifier" - ), - ) - right_join_view = right_join_view.store(synapse_client=self.syn) - self.schedule_for_cleanup(right_join_view.id) - - # AND querying the view - result = right_join_view.query( - f"SELECT * FROM {right_join_view.id}", synapse_client=self.syn - ) - - # THEN the results should match the expected RIGHT JOIN result - assert len(result) == 2 - assert result["unique_identifier"].tolist() == [1, 3] - assert result["name"][0] == "Alice" - assert pd.isna(result["name"][1]) - assert result["age"].tolist() == [30, 40] - - def test_materialized_view_with_inner_join(self, project_model: Project) -> None: - # GIVEN two tables with related data - table1 = Table( - name=str(uuid.uuid4()), - parent_id=project_model.id, - columns=[ - Column(name="unique_identifier", column_type=ColumnType.INTEGER), - Column(name="name", column_type=ColumnType.STRING), - ], - ) - table1 = table1.store(synapse_client=self.syn) - self.schedule_for_cleanup(table1.id) - - table2 = Table( - name=str(uuid.uuid4()), - parent_id=project_model.id, - columns=[ - Column(name="unique_identifier", column_type=ColumnType.INTEGER), - Column(name="age", column_type=ColumnType.INTEGER), - ], - ) - table2 = table2.store(synapse_client=self.syn) - self.schedule_for_cleanup(table2.id) - - data1 = pd.DataFrame({"unique_identifier": [1, 2], "name": ["Alice", "Bob"]}) - table1.store_rows(data1, synapse_client=self.syn) - - data2 = pd.DataFrame({"unique_identifier": [1, 3], "age": [30, 40]}) - table2.store_rows(data2, synapse_client=self.syn) - - # WHEN creating a materialized view with an INNER JOIN - inner_join_view = MaterializedView( - name=str(uuid.uuid4()), - parent_id=project_model.id, - defining_sql=( - f"SELECT t1.unique_identifier as unique_identifier, t1.name as name, " - f"t2.age as age FROM {table1.id} t1 INNER JOIN {table2.id} t2 " - f"ON t1.unique_identifier = t2.unique_identifier" - ), - ) - inner_join_view = inner_join_view.store(synapse_client=self.syn) - self.schedule_for_cleanup(inner_join_view.id) - - # AND querying the view - result = inner_join_view.query( - f"SELECT * FROM {inner_join_view.id}", synapse_client=self.syn - ) - - # THEN the results should match the expected INNER JOIN result - assert len(result) == 1 - assert result["unique_identifier"].tolist() == [1] - assert result["name"].tolist() == ["Alice"] - assert result["age"].tolist() == [30] - - def test_materialized_view_with_union(self, project_model: Project) -> None: - # GIVEN two tables with data - table1 = Table( - name=str(uuid.uuid4()), - parent_id=project_model.id, - columns=[ - Column(name="unique_identifier", column_type=ColumnType.INTEGER), - Column(name="name", column_type=ColumnType.STRING), - ], - ) - table1 = table1.store(synapse_client=self.syn) - self.schedule_for_cleanup(table1.id) - - table2 = Table( - name=str(uuid.uuid4()), - parent_id=project_model.id, - columns=[ - Column(name="unique_identifier", column_type=ColumnType.INTEGER), - Column(name="name", column_type=ColumnType.STRING), - ], - ) - table2 = table2.store(synapse_client=self.syn) - self.schedule_for_cleanup(table2.id) - - data1 = pd.DataFrame({"unique_identifier": [1, 2], "name": ["Alice", "Bob"]}) - table1.store_rows(data1, synapse_client=self.syn) - - data2 = pd.DataFrame( - {"unique_identifier": [3, 4], "name": ["Charlie", "Diana"]} - ) - table2.store_rows(data2, synapse_client=self.syn) - - # WHEN creating a materialized view with a UNION - union_view = MaterializedView( - name=str(uuid.uuid4()), - parent_id=project_model.id, - defining_sql=( - f"SELECT unique_identifier as unique_identifier, name as name " - f"FROM {table1.id} UNION SELECT unique_identifier as unique_identifier, " - f"name as name FROM {table2.id}" - ), - ) - union_view = union_view.store(synapse_client=self.syn) - self.schedule_for_cleanup(union_view.id) - - # AND querying the view - result = union_view.query( - f"SELECT * FROM {union_view.id}", synapse_client=self.syn - ) - - # THEN the results should match the expected UNION result - assert len(result) == 4 - assert result["unique_identifier"].tolist() == [1, 2, 3, 4] - assert result["name"].tolist() == ["Alice", "Bob", "Charlie", "Diana"] diff --git a/tests/integration/synapseclient/models/synchronous/test_permissions.py b/tests/integration/synapseclient/models/synchronous/test_permissions.py deleted file mode 100644 index 03d7fa461..000000000 --- a/tests/integration/synapseclient/models/synchronous/test_permissions.py +++ /dev/null @@ -1,2815 +0,0 @@ -"""Integration tests for ACL on several models.""" - -import logging -import random -import time -import uuid -from typing import Callable, Dict, List, Optional, Type, Union - -import pytest - -from synapseclient import Synapse -from synapseclient.core import utils -from synapseclient.core.models.acl import AclListResult -from synapseclient.models import ( - Column, - ColumnType, - Dataset, - DatasetCollection, - EntityRef, - EntityView, - File, - Folder, - MaterializedView, - Project, - SubmissionView, - Table, - Team, - UserProfile, - ViewTypeMask, - VirtualTable, -) - -PUBLIC = 273949 # PrincipalId of public "user" -AUTHENTICATED_USERS = 273948 - -TEAM_PREFIX = "My Uniquely Named Team " -DESCRIPTION_FAKE_TEAM = "A fake team for testing permissions" - - -class TestAcl: - """Testing ACL on various entity models.""" - - @pytest.fixture(autouse=True, scope="function") - def init(self, syn: Synapse, schedule_for_cleanup: Callable[..., None]) -> None: - self.syn = syn - self.schedule_for_cleanup = schedule_for_cleanup - - @pytest.fixture(scope="function") - def file(self, schedule_for_cleanup: Callable[..., None]) -> File: - filename = utils.make_bogus_uuid_file() - schedule_for_cleanup(filename) - return File(path=filename) - - @pytest.fixture(scope="function") - def table(self, project_model: Project) -> Table: - columns = [ - Column(id=None, name="my_string_column", column_type=ColumnType.STRING), - ] - return Table( - name="my_test_table" + str(uuid.uuid4()), - columns=columns, - parent_id=project_model.id, - ) - - def create_entity( - self, - entity_type: Type[Union[Project, Folder, File, Table]], - project_model: Optional[Project] = None, - file_fixture: Optional[File] = None, - table_fixture: Optional[Table] = None, - name_suffix: str = "", - ) -> Union[Project, Folder, File, Table]: - """Helper to create different entity types with consistent naming""" - entity_name = str(uuid.uuid4()) + name_suffix - - if entity_type == Project: - entity = Project(name=entity_name).store(synapse_client=self.syn) - elif entity_type == Folder: - entity = Folder(name=entity_name).store( - parent=project_model, synapse_client=self.syn - ) - elif entity_type == File: - file_fixture.name = entity_name - entity = file_fixture.store(parent=project_model, synapse_client=self.syn) - elif entity_type == Table: - table_fixture.name = entity_name - entity = table_fixture.store(synapse_client=self.syn) - else: - raise ValueError(f"Unsupported entity type: {entity_type}") - - self.schedule_for_cleanup(entity.id) - return entity - - def create_team(self, description: str = DESCRIPTION_FAKE_TEAM) -> Team: - """Helper to create a team with cleanup handling""" - name = TEAM_PREFIX + str(uuid.uuid4()) - team = Team(name=name, description=description).create(synapse_client=self.syn) - self.schedule_for_cleanup(team) - return team - - @pytest.mark.parametrize("entity_type", [Project, Folder, File, Table]) - def test_get_acl_default( - self, entity_type, project_model: Project, file: File, table: Table - ) -> None: - # GIVEN an entity created with default permissions - entity = self.create_entity( - entity_type, project_model, file, table, name_suffix="_test_get_acl_default" - ) - - # AND the user that created the entity - user = UserProfile().get(synapse_client=self.syn) - - # WHEN getting the permissions for the user on the entity - permissions = entity.get_acl(principal_id=user.id, synapse_client=self.syn) - - # THEN the expected default admin permissions should be present - expected_permissions = [ - "READ", - "DELETE", - "CHANGE_SETTINGS", - "UPDATE", - "CHANGE_PERMISSIONS", - "CREATE", - "MODERATE", - "DOWNLOAD", - ] - assert set(expected_permissions) == set(permissions) - - @pytest.mark.parametrize("entity_type", [Project, Folder, File, Table]) - def test_get_acl_limited_permissions( - self, entity_type, project_model: Project, file: File, table: Table - ) -> None: - # GIVEN an entity created with default permissions - entity = self.create_entity( - entity_type, project_model, file, table, name_suffix="_test_get_acl_limited" - ) - - # AND the user that created the entity - user = UserProfile().get(synapse_client=self.syn) - - # AND the permissions for the user are set to a limited set - limited_permissions = [ - "READ", - "CHANGE_SETTINGS", - "CHANGE_PERMISSIONS", - "UPDATE", - "DELETE", - ] - entity.set_permissions( - principal_id=user.id, - access_type=limited_permissions, - synapse_client=self.syn, - ) - - # WHEN getting the permissions for the user on the entity - permissions = entity.get_acl(principal_id=user.id, synapse_client=self.syn) - - # THEN only the limited permissions should be present - assert set(limited_permissions) == set(permissions) - - @pytest.mark.parametrize("entity_type", [Project, Folder, File, Table]) - def test_get_acl_through_team( - self, entity_type, project_model: Project, file: File, table: Table - ) -> None: - # GIVEN a team - team = self.create_team() - - # AND an entity created with default permissions - entity = self.create_entity( - entity_type, project_model, file, table, name_suffix="_test_get_acl_team" - ) - - # AND the user that created the entity - user = UserProfile().get(synapse_client=self.syn) - - # AND the team has specific permissions (all except DOWNLOAD) - team_permissions = [ - "READ", - "DELETE", - "CHANGE_SETTINGS", - "UPDATE", - "CHANGE_PERMISSIONS", - "CREATE", - "MODERATE", - ] - entity.set_permissions( - principal_id=team.id, - access_type=team_permissions, - synapse_client=self.syn, - ) - - # AND the user has no direct permissions - entity.set_permissions( - principal_id=user.id, access_type=[], synapse_client=self.syn - ) - - # WHEN getting the permissions for the user on the entity - permissions = entity.get_acl(principal_id=user.id, synapse_client=self.syn) - - # THEN the permissions should match the team's permissions - assert set(team_permissions) == set(permissions) - - @pytest.mark.parametrize("entity_type", [Project, Folder, File, Table]) - def test_get_acl_through_multiple_teams( - self, entity_type, project_model: Project, file: File, table: Table - ) -> None: - # GIVEN two teams - team_1 = self.create_team(description=f"{DESCRIPTION_FAKE_TEAM} - 1") - team_2 = self.create_team(description=f"{DESCRIPTION_FAKE_TEAM} - 2") - - # AND an entity created with default permissions - entity = self.create_entity( - entity_type, - project_model, - file, - table, - name_suffix="_test_get_acl_multiple_teams", - ) - - # AND the user that created the entity - user = UserProfile().get(synapse_client=self.syn) - - # AND the first team has specific permissions (all except DOWNLOAD) - team_1_permissions = [ - "READ", - "DELETE", - "CHANGE_SETTINGS", - "UPDATE", - "CHANGE_PERMISSIONS", - "CREATE", - "MODERATE", - ] - entity.set_permissions( - principal_id=team_1.id, - access_type=team_1_permissions, - synapse_client=self.syn, - ) - - # AND the second team has only READ and DOWNLOAD permissions - team_2_permissions = ["READ", "DOWNLOAD"] - entity.set_permissions( - principal_id=team_2.id, - access_type=team_2_permissions, - synapse_client=self.syn, - ) - - # AND the user has no direct permissions - entity.set_permissions( - principal_id=user.id, access_type=[], synapse_client=self.syn - ) - - # WHEN getting the permissions for the user on the entity - permissions = entity.get_acl(principal_id=user.id, synapse_client=self.syn) - - # THEN the permissions should be the combined set from both teams - expected_permissions = [ - "READ", - "DELETE", - "CHANGE_SETTINGS", - "UPDATE", - "CHANGE_PERMISSIONS", - "CREATE", - "MODERATE", - "DOWNLOAD", - ] - assert set(expected_permissions) == set(permissions) - - @pytest.mark.parametrize("entity_type", [Project, Folder, File, Table]) - def test_get_acl_with_public_and_authenticated_users( - self, entity_type, project_model: Project, file: File, table: Table - ) -> None: - # GIVEN an entity created with default permissions - entity = self.create_entity( - entity_type, - project_model, - file, - table, - name_suffix="_test_get_acl_public_auth", - ) - - # AND the user that created the entity - user = UserProfile().get(synapse_client=self.syn) - - # AND public users have READ permission - entity.set_permissions( - principal_id=PUBLIC, access_type=["READ"], synapse_client=self.syn - ) - - # AND authenticated users have READ and DOWNLOAD permissions - entity.set_permissions( - principal_id=AUTHENTICATED_USERS, - access_type=["READ", "DOWNLOAD"], - synapse_client=self.syn, - ) - - # AND the user has specific permissions (excluding DOWNLOAD) - user_permissions = [ - "READ", - "DELETE", - "CHANGE_SETTINGS", - "UPDATE", - "CHANGE_PERMISSIONS", - "CREATE", - "MODERATE", - ] - entity.set_permissions( - principal_id=user.id, - access_type=user_permissions, - synapse_client=self.syn, - ) - - # WHEN getting public permissions (no principal_id) - public_permissions = entity.get_acl(synapse_client=self.syn) - - # THEN only public permissions should be present - assert set(["READ"]) == set(public_permissions) - - # WHEN getting the permissions for the authenticated user - user_permissions = entity.get_acl(principal_id=user.id, synapse_client=self.syn) - - # THEN the permissions should include direct user permissions plus - # permissions from authenticated users and public users - expected_permissions = [ - "READ", - "DELETE", - "CHANGE_SETTINGS", - "UPDATE", - "CHANGE_PERMISSIONS", - "CREATE", - "MODERATE", - "DOWNLOAD", - ] - assert set(expected_permissions) == set(user_permissions) - - def test_get_acl_for_subfolder_with_different_permissions( - self, project_model: Project - ) -> None: - # GIVEN a parent folder with default permissions - parent_folder = Folder(name=str(uuid.uuid4()) + "_parent_folder_test").store( - parent=project_model, synapse_client=self.syn - ) - self.schedule_for_cleanup(parent_folder.id) - - # AND a subfolder created inside the parent folder - subfolder = Folder(name=str(uuid.uuid4()) + "_subfolder_test").store( - parent=parent_folder, synapse_client=self.syn - ) - self.schedule_for_cleanup(subfolder.id) - - # AND the user that created the folders - user = UserProfile().get(synapse_client=self.syn) - - # AND the subfolder has limited permissions - limited_permissions = [ - "READ", - "CHANGE_SETTINGS", - "CHANGE_PERMISSIONS", - "UPDATE", - "DELETE", - ] - subfolder.set_permissions( - principal_id=user.id, - access_type=limited_permissions, - synapse_client=self.syn, - ) - - # WHEN getting permissions for the subfolder - subfolder_permissions = subfolder.get_acl( - principal_id=user.id, synapse_client=self.syn - ) - - # AND getting permissions for the parent folder - parent_permissions = parent_folder.get_acl( - principal_id=user.id, synapse_client=self.syn - ) - - # THEN the subfolder should have the limited permissions - assert set(limited_permissions) == set(subfolder_permissions) - - # AND the parent folder should have the default admin permissions - expected_parent_permissions = [ - "READ", - "DELETE", - "CHANGE_SETTINGS", - "UPDATE", - "CHANGE_PERMISSIONS", - "CREATE", - "MODERATE", - "DOWNLOAD", - ] - assert set(expected_parent_permissions) == set(parent_permissions) - - @pytest.mark.parametrize("entity_type", [Project, Folder, File, Table]) - def test_remove_team_permissions_with_empty_access_type( - self, entity_type, project_model: Project, file: File, table: Table - ) -> None: - # GIVEN an entity created with default permissions - entity = self.create_entity( - entity_type, - project_model, - file, - table, - name_suffix="_test_remove_team_permissions", - ) - - # AND a test team - team = self.create_team() - - # AND the team initially has specific permissions - initial_team_permissions = ["READ", "UPDATE", "CREATE", "DOWNLOAD"] - entity.set_permissions( - principal_id=team.id, - access_type=initial_team_permissions, - synapse_client=self.syn, - ) - - # WHEN verifying the team has the initial permissions - team_acl_before = entity.get_acl(principal_id=team.id, synapse_client=self.syn) - assert set(initial_team_permissions) == set(team_acl_before) - - # AND WHEN removing the team's permissions by setting access_type to empty list - entity.set_permissions( - principal_id=team.id, - access_type=[], # Empty list to remove permissions - synapse_client=self.syn, - ) - - # THEN the team should have no permissions - team_acl_after = entity.get_acl(principal_id=team.id, synapse_client=self.syn) - assert team_acl_after == [] - - # AND the team should not appear in the full ACL list with any permissions - all_acls = entity.list_acl(synapse_client=self.syn) - team_acl_entries = [ - acl - for acl in all_acls.entity_acl - if acl.principal_id == team.id and acl.access_type - ] - assert ( - len(team_acl_entries) == 0 - ), f"Team {team.id} should have no ACL entries but found: {team_acl_entries}" - - # AND other entities should still maintain their permissions (verify no side effects) - user = UserProfile().get(synapse_client=self.syn) - user_acl = entity.get_acl(principal_id=user.id, synapse_client=self.syn) - assert len(user_acl) > 0, "User permissions should remain intact" - - def test_table_permissions(self, project_model: Project) -> None: - """Comprehensive test for Table permissions - setting, listing, and deleting.""" - # GIVEN a table with test data - columns = [ - Column(id=None, name="test_column", column_type=ColumnType.STRING), - Column(id=None, name="number_column", column_type=ColumnType.INTEGER), - ] - table = Table( - name=f"test_table_permissions_{uuid.uuid4()}", - columns=columns, - parent_id=project_model.id, - ) - table = table.store(synapse_client=self.syn) - self.schedule_for_cleanup(table.id) - - # AND a test team - team = self.create_team() - user = UserProfile().get(synapse_client=self.syn) - - # WHEN setting various permissions - # Set team permissions - team_permissions = ["READ", "UPDATE", "CREATE"] - table.set_permissions( - principal_id=team.id, access_type=team_permissions, synapse_client=self.syn - ) - - # Set authenticated users permissions - table.set_permissions( - principal_id=AUTHENTICATED_USERS, - access_type=["READ", "DOWNLOAD"], - synapse_client=self.syn, - ) - - time.sleep(random.randint(10, 20)) - - # THEN listing permissions should show all set permissions - # Check team permissions - team_acl = table.get_acl(principal_id=team.id, synapse_client=self.syn) - assert set(team_permissions) == set(team_acl) - - # Check authenticated users permissions - auth_acl = table.get_acl( - principal_id=AUTHENTICATED_USERS, synapse_client=self.syn - ) - assert set(["READ", "DOWNLOAD"]) == set(auth_acl) - - # Check user effective permissions (should include permissions from all sources) - user_effective_acl = table.get_acl( - principal_id=user.id, synapse_client=self.syn - ) - expected_user_permissions = { - "READ", - "DELETE", - "CHANGE_SETTINGS", - "UPDATE", - "CHANGE_PERMISSIONS", - "CREATE", - "MODERATE", - "DOWNLOAD", - } - assert expected_user_permissions.issubset(set(user_effective_acl)) - - # AND listing all ACLs should return complete ACL information - all_acls = table.list_acl(synapse_client=self.syn) - assert isinstance(all_acls, AclListResult) - assert len(all_acls.entity_acl) >= 3 # User, team, authenticated_users - - # WHEN deleting specific permissions for the team - table.set_permissions( - principal_id=team.id, access_type=[], synapse_client=self.syn - ) - - time.sleep(random.randint(10, 20)) - - # THEN team should no longer have permissions - team_acl_after_delete = table.get_acl( - principal_id=team.id, synapse_client=self.syn - ) - assert team_acl_after_delete == [] - - # BUT other permissions should remain - auth_acl_after = table.get_acl( - principal_id=AUTHENTICATED_USERS, synapse_client=self.syn - ) - assert set(["READ", "DOWNLOAD"]) == set(auth_acl_after) - - def test_entity_view_permissions(self, project_model: Project) -> None: - """Comprehensive test for EntityView permissions - setting, listing, and deleting.""" - # GIVEN an entity view - entity_view = EntityView( - name=f"test_entity_view_permissions_{uuid.uuid4()}", - parent_id=project_model.id, - scope_ids=[project_model.id], - view_type_mask=0x01, # File view - ) - entity_view = entity_view.store(synapse_client=self.syn) - self.schedule_for_cleanup(entity_view.id) - - # AND test subjects - team = self.create_team() - user = UserProfile().get(synapse_client=self.syn) - - # WHEN setting comprehensive permissions - # Set team permissions (moderate permissions) - team_permissions = ["READ", "UPDATE", "MODERATE"] - entity_view.set_permissions( - principal_id=team.id, access_type=team_permissions, synapse_client=self.syn - ) - - # Set authenticated users permissions - entity_view.set_permissions( - principal_id=AUTHENTICATED_USERS, - access_type=["READ", "DOWNLOAD"], - synapse_client=self.syn, - ) - - # Set limited user permissions (remove some admin permissions) - limited_user_permissions = [ - "READ", - "UPDATE", - "CHANGE_SETTINGS", - "CHANGE_PERMISSIONS", - "DELETE", - "MODERATE", - ] - entity_view.set_permissions( - principal_id=user.id, - access_type=limited_user_permissions, - synapse_client=self.syn, - ) - - time.sleep(random.randint(10, 20)) - - # THEN listing permissions should reflect all changes - # Verify team permissions - team_acl = entity_view.get_acl(principal_id=team.id, synapse_client=self.syn) - assert set(team_permissions) == set(team_acl) - - # Verify authenticated users permissions - auth_acl = entity_view.get_acl( - principal_id=AUTHENTICATED_USERS, synapse_client=self.syn - ) - assert set(["READ", "DOWNLOAD"]) == set(auth_acl) - - # Verify user permissions include both direct and inherited permissions - user_acl = entity_view.get_acl(principal_id=user.id, synapse_client=self.syn) - expected_user_permissions = set( - limited_user_permissions + ["DOWNLOAD"] - ) # Includes auth users perm - assert expected_user_permissions == set(user_acl) - - # Verify complete ACL listing - all_acls = entity_view.list_acl(synapse_client=self.syn) - assert isinstance(all_acls, AclListResult) - assert len(all_acls.entity_acl) >= 3 # User, team, authenticated_users - - # WHEN deleting authenticated users permissions - entity_view.set_permissions( - principal_id=AUTHENTICATED_USERS, access_type=[], synapse_client=self.syn - ) - - time.sleep(random.randint(10, 20)) - - # THEN authenticated users should lose permissions - auth_acl_after = entity_view.get_acl( - principal_id=AUTHENTICATED_USERS, synapse_client=self.syn - ) - assert auth_acl_after == [] - - # AND user permissions should no longer include DOWNLOAD - user_acl_after = entity_view.get_acl( - principal_id=user.id, synapse_client=self.syn - ) - assert set(limited_user_permissions + ["MODERATE"]) == set(user_acl_after) - - # BUT team permissions should remain - team_acl_after = entity_view.get_acl( - principal_id=team.id, synapse_client=self.syn - ) - assert set(team_permissions) == set(team_acl_after) - - def test_submission_view_permissions(self, project_model: Project) -> None: - """Comprehensive test for SubmissionView permissions - setting, listing, and deleting.""" - # GIVEN a submission view - submission_view = SubmissionView( - name=f"test_submission_view_permissions_{uuid.uuid4()}", - parent_id=project_model.id, - scope_ids=[project_model.id], - ) - submission_view = submission_view.store(synapse_client=self.syn) - self.schedule_for_cleanup(submission_view) - - # AND test subjects - team1 = self.create_team(description=f"{DESCRIPTION_FAKE_TEAM} - team1") - team2 = self.create_team(description=f"{DESCRIPTION_FAKE_TEAM} - team2") - user = UserProfile().get(synapse_client=self.syn) - - # WHEN setting overlapping permissions across multiple teams - # Team1 gets full read/write access - team1_permissions = ["READ", "UPDATE", "CREATE", "DELETE"] - submission_view.set_permissions( - principal_id=team1.id, - access_type=team1_permissions, - synapse_client=self.syn, - ) - - # Team2 gets read-only access with download - team2_permissions = ["READ", "DOWNLOAD"] - submission_view.set_permissions( - principal_id=team2.id, - access_type=team2_permissions, - synapse_client=self.syn, - ) - - # Public gets read access - submission_view.set_permissions( - principal_id=PUBLIC, access_type=["READ"], synapse_client=self.syn - ) - - # User gets minimal direct permissions - user_direct_permissions = ["READ", "CHANGE_SETTINGS", "CHANGE_PERMISSIONS"] - submission_view.set_permissions( - principal_id=user.id, - access_type=user_direct_permissions, - synapse_client=self.syn, - ) - - time.sleep(random.randint(10, 20)) - - # THEN listing permissions should show proper aggregation - # Check individual team permissions - team1_acl = submission_view.get_acl( - principal_id=team1.id, synapse_client=self.syn - ) - assert set(team1_permissions) == set(team1_acl) - - team2_acl = submission_view.get_acl( - principal_id=team2.id, synapse_client=self.syn - ) - assert set(team2_permissions) == set(team2_acl) - - # Check public permissions - public_acl = submission_view.get_acl(synapse_client=self.syn) - assert set(["READ"]) == set(public_acl) - - # Check user effective permissions (should aggregate from all teams) - user_effective_acl = submission_view.get_acl( - principal_id=user.id, synapse_client=self.syn - ) - expected_effective = set( - user_direct_permissions + team1_permissions + team2_permissions - ) - assert expected_effective == set(user_effective_acl) - - # Verify complete ACL structure - all_acls = submission_view.list_acl(synapse_client=self.syn) - assert isinstance(all_acls, AclListResult) - assert len(all_acls.entity_acl) >= 4 # User, team1, team2, public - - # WHEN selectively deleting permissions - # Remove PUBLIC and team permissions - submission_view.set_permissions( - principal_id=PUBLIC, access_type=[], synapse_client=self.syn - ) - submission_view.set_permissions( - principal_id=team1.id, access_type=[], synapse_client=self.syn - ) - - # THEN PUBLIC should lose all permissions - public_acl_after = submission_view.get_acl( - principal_id=PUBLIC, synapse_client=self.syn - ) - assert public_acl_after == [] - - # AND team should lose all permissions - team_acl_after = submission_view.get_acl( - principal_id=team1.id, synapse_client=self.syn - ) - assert team_acl_after == [] - - # AND user effective permissions should no longer include team1 permissions - user_effective_after = submission_view.get_acl( - principal_id=user.id, synapse_client=self.syn - ) - expected_after = set(user_direct_permissions + team2_permissions) - assert expected_after == set(user_effective_after) - - # BUT other permissions should remain unchanged - team2_acl_after = submission_view.get_acl( - principal_id=team2.id, synapse_client=self.syn - ) - assert set(team2_permissions) == set(team2_acl_after) - - -class TestPermissionsForCaller: - """Test the permissions that the current caller has for an entity.""" - - @pytest.fixture(autouse=True, scope="function") - def init(self, syn: Synapse, schedule_for_cleanup: Callable[..., None]) -> None: - self.syn = syn - self.schedule_for_cleanup = schedule_for_cleanup - - def create_team(self, description: str = DESCRIPTION_FAKE_TEAM) -> Team: - """Helper to create a team with cleanup handling""" - name = TEAM_PREFIX + str(uuid.uuid4()) - team = Team(name=name, description=description).create(synapse_client=self.syn) - self.schedule_for_cleanup(team) - return team - - def test_get_permissions_default(self) -> None: - # GIVEN a project created with default permissions - project = Project( - name=str(uuid.uuid4()) + "_test_get_permissions_default" - ).store(synapse_client=self.syn) - self.schedule_for_cleanup(project.id) - - # WHEN getting the permissions for the current user - permissions = project.get_permissions(synapse_client=self.syn) - - # THEN all default permissions should be present - expected_permissions = [ - "READ", - "DELETE", - "CHANGE_SETTINGS", - "UPDATE", - "CHANGE_PERMISSIONS", - "CREATE", - "MODERATE", - "DOWNLOAD", - ] - assert set(expected_permissions) == set(permissions.access_types) - - def test_get_permissions_with_limited_access(self) -> None: - # GIVEN a project created with default permissions - project = Project( - name=str(uuid.uuid4()) + "_test_get_permissions_limited" - ).store(synapse_client=self.syn) - self.schedule_for_cleanup(project.id) - - # AND the current user that created the project - user = UserProfile().get(synapse_client=self.syn) - - # AND the permissions for the user are set to READ only - project.set_permissions( - principal_id=user.id, access_type=["READ"], synapse_client=self.syn - ) - - # WHEN getting the permissions for the current user - permissions = project.get_permissions(synapse_client=self.syn) - - # THEN READ and CHANGE_SETTINGS permissions should be present - # Note: CHANGE_SETTINGS is bound to ownerId and can't be removed - expected_permissions = ["READ", "CHANGE_SETTINGS"] - assert set(expected_permissions) == set(permissions.access_types) - - def test_get_permissions_through_teams(self) -> None: - # GIVEN two teams - team_1 = self.create_team(description=f"{DESCRIPTION_FAKE_TEAM} - 1") - team_2 = self.create_team(description=f"{DESCRIPTION_FAKE_TEAM} - 2") - - # AND a project created with default permissions - project = Project( - name=str(uuid.uuid4()) + "_test_get_permissions_through_teams" - ).store(synapse_client=self.syn) - self.schedule_for_cleanup(project.id) - - # AND the current user that created the project - user = UserProfile().get(synapse_client=self.syn) - - # AND team 1 has all permissions except DOWNLOAD - team_1_permissions = [ - "READ", - "DELETE", - "CHANGE_SETTINGS", - "UPDATE", - "CHANGE_PERMISSIONS", - "CREATE", - "MODERATE", - ] - project.set_permissions( - principal_id=team_1.id, - access_type=team_1_permissions, - synapse_client=self.syn, - ) - - # AND team 2 has only READ and DOWNLOAD permissions - project.set_permissions( - principal_id=team_2.id, - access_type=["READ", "DOWNLOAD"], - synapse_client=self.syn, - ) - - # AND the user has no direct permissions - project.set_permissions( - principal_id=user.id, access_type=[], synapse_client=self.syn - ) - - # WHEN getting the permissions for the current user - permissions = project.get_permissions(synapse_client=self.syn) - - # THEN the effective permissions should be the combined permissions from both teams - expected_permissions = [ - "READ", - "DELETE", - "CHANGE_SETTINGS", - "UPDATE", - "CHANGE_PERMISSIONS", - "CREATE", - "MODERATE", - "DOWNLOAD", - ] - assert set(expected_permissions) == set(permissions.access_types) - - def test_get_permissions_with_authenticated_users(self) -> None: - # GIVEN a project created with default permissions - project = Project( - name=str(uuid.uuid4()) + "_test_get_permissions_authenticated" - ).store(synapse_client=self.syn) - self.schedule_for_cleanup(project.id) - - # AND the current user that created the project - user = UserProfile().get(synapse_client=self.syn) - - # AND authenticated users have READ and DOWNLOAD permissions - project.set_permissions( - principal_id=AUTHENTICATED_USERS, - access_type=["READ", "DOWNLOAD"], - synapse_client=self.syn, - ) - - # AND the current user has specific permissions (without DOWNLOAD) - user_permissions = [ - "READ", - "DELETE", - "CHANGE_SETTINGS", - "UPDATE", - "CHANGE_PERMISSIONS", - "CREATE", - "MODERATE", - ] - project.set_permissions( - principal_id=user.id, - access_type=user_permissions, - synapse_client=self.syn, - ) - - # WHEN getting the permissions for the current user - permissions = project.get_permissions(synapse_client=self.syn) - - # THEN the permissions should include user permissions plus - # the DOWNLOAD permission from authenticated users - expected_permissions = [ - "READ", - "DELETE", - "CHANGE_SETTINGS", - "UPDATE", - "CHANGE_PERMISSIONS", - "CREATE", - "MODERATE", - "DOWNLOAD", - ] - assert set(expected_permissions) == set(permissions.access_types) - - -class TestDeletePermissions: - """Test delete_permissions functionality across entities.""" - - @pytest.fixture(autouse=True, scope="function") - def init( - self, - syn: Synapse, - syn_with_logger: Synapse, - schedule_for_cleanup: Callable[..., None], - ) -> None: - self.syn = syn - self.schedule_for_cleanup = schedule_for_cleanup - self.syn_with_logger = syn_with_logger - self.verification_attempts = 10 - - @pytest.fixture(scope="function") - def project_object(self) -> Project: - return Project(name="integration_test_project" + str(uuid.uuid4())) - - @pytest.fixture(scope="function") - def file(self, schedule_for_cleanup: Callable[..., None]) -> File: - filename = utils.make_bogus_uuid_file() - schedule_for_cleanup(filename) - return File(path=filename) - - def _set_custom_permissions(self, entity: Union[File, Folder, Project]) -> None: - """Helper to set custom permissions on an entity so we can verify deletion.""" - # Set custom permissions for authenticated users - entity.set_permissions( - principal_id=AUTHENTICATED_USERS, - access_type=["READ"], - synapse_client=self.syn, - ) - - # Verify permissions were set - acl = entity.get_acl(principal_id=AUTHENTICATED_USERS, synapse_client=self.syn) - assert "READ" in acl - - return acl - - def _verify_permissions_deleted(self, entity: Union[File, Folder, Project]) -> None: - """Helper to verify that permissions have been deleted (entity inherits from parent).""" - for attempt in range(self.verification_attempts): - time.sleep(random.randint(10, 20)) - - acl = entity.get_acl( - principal_id=AUTHENTICATED_USERS, - check_benefactor=False, - synapse_client=self.syn, - ) - - if not acl: - return # Verification successful - - if attempt == self.verification_attempts - 1: # Last attempt - assert not acl, ( - f"Permissions should be deleted, but they still exist on " - f"[id: {entity.id}, name: {entity.name}, {entity.__class__}]." - ) - - def _verify_permissions_not_deleted( - self, entity: Union[File, Folder, Project] - ) -> bool: - """Helper to verify that permissions are still set on an entity.""" - for attempt in range(self.verification_attempts): - time.sleep(random.randint(10, 20)) - acl = entity.get_acl( - principal_id=AUTHENTICATED_USERS, - check_benefactor=False, - synapse_client=self.syn, - ) - if "READ" in acl: - return True - - if attempt == self.verification_attempts - 1: # Last attempt - assert "READ" in acl - - return True - - def _verify_list_acl_functionality( - self, - entity: Union[File, Folder, Project], - expected_entity_count: int, - recursive: bool = True, - include_container_content: bool = True, - target_entity_types: Optional[List[str]] = None, - log_tree: bool = True, - ) -> AclListResult: - """Helper to verify list_acl functionality and return results.""" - for attempt in range(self.verification_attempts): - time.sleep(random.randint(10, 20)) - acl_result = entity.list_acl( - recursive=recursive, - include_container_content=include_container_content, - target_entity_types=target_entity_types, - log_tree=log_tree, - synapse_client=self.syn_with_logger, - ) - - if ( - isinstance(acl_result, AclListResult) - and len(acl_result.all_entity_acls) >= expected_entity_count - ): - return acl_result - - if attempt == self.verification_attempts - 1: # Last attempt - assert isinstance(acl_result, AclListResult) - assert len(acl_result.all_entity_acls) >= expected_entity_count - - return acl_result - - def _verify_log_messages( - self, - caplog: pytest.LogCaptureFixture, - list_acl_called: bool = True, - delete_permissions_called: bool = True, - dry_run: bool = False, - tree_logging: bool = True, - ) -> None: - """Helper to verify expected log messages from both methods.""" - for attempt in range(self.verification_attempts): - log_text = caplog.text - all_checks_passed = True - - # Check tree logging if required - if list_acl_called and tree_logging: - if "ACL Tree Structure:" not in log_text: - all_checks_passed = False - - # Check dry run messages if required - if delete_permissions_called and dry_run: - if ( - "DRY RUN" not in log_text - or "Permission Deletion Impact Analysis" not in log_text - or "End of Dry Run Analysis" not in log_text - ): - all_checks_passed = False - - # If all checks passed, we're done - if all_checks_passed: - break - - # On last attempt, assert all required conditions - if attempt == self.verification_attempts - 1: - if list_acl_called and tree_logging: - assert "ACL Tree Structure:" in log_text - if delete_permissions_called and dry_run: - assert "DRY RUN" in log_text - assert "Permission Deletion Impact Analysis" in log_text - assert "End of Dry Run Analysis" in log_text - - def create_simple_tree_structure( - self, project_model: Project - ) -> Dict[str, Union[Folder, File]]: - """ - Create a simple 2-level tree structure. - - Structure: - ``` - Project - └── folder_a - └── file_1 - ``` - """ - folder_a = Folder(name=f"folder_a_{uuid.uuid4()}").store( - parent=project_model, synapse_client=self.syn - ) - self.schedule_for_cleanup(folder_a.id) - - file_1 = File( - path=utils.make_bogus_uuid_file(), name=f"file_1_{uuid.uuid4()}" - ).store(parent=folder_a, synapse_client=self.syn) - self.schedule_for_cleanup(file_1.id) - - return { - "folder_a": folder_a, - "file_1": file_1, - } - - def create_deep_nested_structure( - self, project_model: Project - ) -> Dict[str, Union[Folder, File]]: - """ - Create a deeply nested folder structure with files at various levels. - - Structure: - ``` - Project - └── level_1 - ├── file_at_1 - └── level_2 - ├── file_at_2 - └── level_3 - ├── file_at_3 - └── level_4 - └── file_at_4 - ``` - """ - level_1 = Folder(name=f"level_1_{uuid.uuid4()}").store( - parent=project_model, synapse_client=self.syn - ) - self.schedule_for_cleanup(level_1.id) - - # Create file_at_1 and level_2 in parallel since they don't depend on each other - file_at_1 = File( - path=utils.make_bogus_uuid_file(), name=f"file_at_1_{uuid.uuid4()}" - ).store(parent=level_1, synapse_client=self.syn) - level_2 = Folder(name=f"level_2_{uuid.uuid4()}").store( - parent=level_1, synapse_client=self.syn - ) - - self.schedule_for_cleanup(file_at_1.id) - self.schedule_for_cleanup(level_2.id) - - # Create file_at_2 and level_3 in parallel since they don't depend on each other - file_at_2 = File( - path=utils.make_bogus_uuid_file(), name=f"file_at_2_{uuid.uuid4()}" - ).store(parent=level_2, synapse_client=self.syn) - level_3 = Folder(name=f"level_3_{uuid.uuid4()}").store( - parent=level_2, synapse_client=self.syn - ) - - self.schedule_for_cleanup(file_at_2.id) - self.schedule_for_cleanup(level_3.id) - - # Create file_at_3 and level_4 in parallel since they don't depend on each other - file_at_3 = File( - path=utils.make_bogus_uuid_file(), name=f"file_at_3_{uuid.uuid4()}" - ).store(parent=level_3, synapse_client=self.syn) - level_4 = Folder(name=f"level_4_{uuid.uuid4()}").store( - parent=level_3, synapse_client=self.syn - ) - - self.schedule_for_cleanup(file_at_3.id) - self.schedule_for_cleanup(level_4.id) - - file_at_4 = File( - path=utils.make_bogus_uuid_file(), name=f"file_at_4_{uuid.uuid4()}" - ).store(parent=level_4, synapse_client=self.syn) - self.schedule_for_cleanup(file_at_4.id) - - return { - "level_1": level_1, - "level_2": level_2, - "level_3": level_3, - "level_4": level_4, - "file_at_1": file_at_1, - "file_at_2": file_at_2, - "file_at_3": file_at_3, - "file_at_4": file_at_4, - } - - def create_wide_tree_structure( - self, project_model: Project - ) -> Dict[str, Union[Folder, List[Union[Folder, File]]]]: - """ - Create a wide tree structure with multiple siblings. - - Structure: - ``` - Project - ├── folder_a - │ └── file_a - ├── folder_b - │ └── file_b - ├── folder_c - │ └── file_c - └── root_file - ``` - """ - # Create folders in parallel - folders = [ - Folder(name=f"folder_{folder_letter}_{uuid.uuid4()}").store( - parent=project_model, synapse_client=self.syn - ) - for folder_letter in ["a", "b", "c"] - ] - - # Schedule cleanup for folders - for folder in folders: - self.schedule_for_cleanup(folder.id) - - # Create files - file_results = [ - File( - path=utils.make_bogus_uuid_file(), - name=f"file_{folder_letter}_{uuid.uuid4()}", - ).store(parent=folder, synapse_client=self.syn) - for folder_letter, folder in zip(["a", "b", "c"], folders) - ] - - # Create root file task - root_file = File( - path=utils.make_bogus_uuid_file(), name=f"root_file_{uuid.uuid4()}" - ).store(parent=project_model, synapse_client=self.syn) - file_results.append(root_file) - - all_files = file_results[:-1] # All but the last (root file) - root_file = file_results[-1] # The last one (root file) - - # Schedule cleanup for files - for file in all_files: - self.schedule_for_cleanup(file.id) - self.schedule_for_cleanup(root_file.id) - - return { - "folders": folders, - "all_files": all_files, - "root_file": root_file, - } - - def create_complex_mixed_structure( - self, project_model: Project - ) -> Dict[str, Union[Folder, File, List]]: - """ - Create a complex mixed structure combining depth and width. - - Structure: - ``` - Project - ├── shallow_folder - │ └── shallow_file - ├── deep_branch - │ ├── deep_file_1 - │ └── sub_deep - │ ├── deep_file_2 - │ └── sub_sub_deep - │ └── deep_file_3 - └── mixed_folder - ├── mixed_file - ├── mixed_sub_a - │ └── mixed_file_a - └── mixed_sub_b - └── mixed_file_b - ``` - """ - # Create top-level folders - shallow_folder = Folder(name=f"shallow_folder_{uuid.uuid4()}").store( - parent=project_model, synapse_client=self.syn - ) - deep_branch = Folder(name=f"deep_branch_{uuid.uuid4()}").store( - parent=project_model, synapse_client=self.syn - ) - mixed_folder = Folder(name=f"mixed_folder_{uuid.uuid4()}").store( - parent=project_model, synapse_client=self.syn - ) - - # Schedule cleanup for top-level folders - for folder in [shallow_folder, deep_branch, mixed_folder]: - self.schedule_for_cleanup(folder.id) - - # Create first level files and folders - shallow_file = File( - path=utils.make_bogus_uuid_file(), name=f"shallow_file_{uuid.uuid4()}" - ).store(parent=shallow_folder, synapse_client=self.syn) - self.schedule_for_cleanup(shallow_file.id) - - # Deep branch structure - deep_file_1 = File( - path=utils.make_bogus_uuid_file(), name=f"deep_file_1_{uuid.uuid4()}" - ).store(parent=deep_branch, synapse_client=self.syn) - - sub_deep = Folder(name=f"sub_deep_{uuid.uuid4()}").store( - parent=deep_branch, synapse_client=self.syn - ) - - self.schedule_for_cleanup(deep_file_1.id) - self.schedule_for_cleanup(sub_deep.id) - - # Continue deep structure - deep_file_2 = File( - path=utils.make_bogus_uuid_file(), name=f"deep_file_2_{uuid.uuid4()}" - ).store(parent=sub_deep, synapse_client=self.syn) - - sub_sub_deep = Folder(name=f"sub_sub_deep_{uuid.uuid4()}").store( - parent=sub_deep, synapse_client=self.syn - ) - - self.schedule_for_cleanup(deep_file_2.id) - self.schedule_for_cleanup(sub_sub_deep.id) - - deep_file_3 = File( - path=utils.make_bogus_uuid_file(), name=f"deep_file_3_{uuid.uuid4()}" - ).store(parent=sub_sub_deep, synapse_client=self.syn) - self.schedule_for_cleanup(deep_file_3.id) - - # Mixed folder structure - mixed_file = File( - path=utils.make_bogus_uuid_file(), name=f"mixed_file_{uuid.uuid4()}" - ).store(parent=mixed_folder, synapse_client=self.syn) - - mixed_sub_a = Folder(name=f"mixed_sub_a_{uuid.uuid4()}").store( - parent=mixed_folder, synapse_client=self.syn - ) - mixed_sub_b = Folder(name=f"mixed_sub_b_{uuid.uuid4()}").store( - parent=mixed_folder, synapse_client=self.syn - ) - - # Schedule cleanup - self.schedule_for_cleanup(mixed_file.id) - self.schedule_for_cleanup(mixed_sub_a.id) - self.schedule_for_cleanup(mixed_sub_b.id) - - # Create files in mixed sub-folders in parallel - mixed_file_a = File( - path=utils.make_bogus_uuid_file(), name=f"mixed_file_a_{uuid.uuid4()}" - ).store(parent=mixed_sub_a, synapse_client=self.syn) - - mixed_file_b = File( - path=utils.make_bogus_uuid_file(), name=f"mixed_file_b_{uuid.uuid4()}" - ).store(parent=mixed_sub_b, synapse_client=self.syn) - - self.schedule_for_cleanup(mixed_file_a.id) - self.schedule_for_cleanup(mixed_file_b.id) - - return { - "shallow_folder": shallow_folder, - "shallow_file": shallow_file, - "deep_branch": deep_branch, - "sub_deep": sub_deep, - "sub_sub_deep": sub_sub_deep, - "deep_files": [deep_file_1, deep_file_2, deep_file_3], - "mixed_folder": mixed_folder, - "mixed_sub_folders": [mixed_sub_a, mixed_sub_b], - "mixed_files": [mixed_file, mixed_file_a, mixed_file_b], - } - - def test_delete_permissions_on_new_project( - self, caplog: pytest.LogCaptureFixture - ) -> None: - """Test deleting permissions on a newly created project.""" - # Set the log level to capture DEBUG messages - caplog.set_level(logging.DEBUG) - - # GIVEN a newly created project with custom permissions - project = Project(name=f"test_project_{uuid.uuid4()}").store( - synapse_client=self.syn - ) - self.schedule_for_cleanup(project.id) - - # AND custom permissions are set for authenticated users - self._set_custom_permissions(project) - time.sleep(random.randint(10, 20)) - - # WHEN I delete permissions on the project - project.delete_permissions(synapse_client=self.syn) - - # THEN the permissions should not be deleted - # Check either for the log message or verify the permissions still exist - if ( - "Cannot restore inheritance for resource which has no parent." - in caplog.text - ): - # Original assertion passes if the log is captured - assert True - else: - # Alternatively, verify that the permissions weren't actually deleted - # by checking if they still exist - assert self._verify_permissions_not_deleted(project) - - def test_delete_permissions_simple_tree_structure( - self, project_object: Project, caplog: pytest.LogCaptureFixture - ) -> None: - """Test deleting permissions on a simple tree structure.""" - project_object.store(synapse_client=self.syn) - self.schedule_for_cleanup(project_object.id) - - # GIVEN a simple tree structure with permissions - structure = self.create_simple_tree_structure(project_object) - folder_a = structure["folder_a"] - file_1 = structure["file_1"] - - # Set permissions on all entities - self._set_custom_permissions(folder_a) - self._set_custom_permissions(file_1) - time.sleep(random.randint(10, 20)) - - # WHEN - Verify list_acl before deletion - self._verify_list_acl_functionality( - entity=folder_a, - expected_entity_count=2, # folder_a and file_1 - recursive=True, - include_container_content=True, - log_tree=True, - ) - - # Verify tree logging occurred - assert "ACL Tree Structure:" in caplog.text - caplog.clear() # Clear logs for next verification - - # WHEN I delete permissions recursively - folder_a.delete_permissions( - recursive=True, - include_container_content=True, - dry_run=False, - synapse_client=self.syn, - ) - - # THEN all permissions should be deleted - self._verify_permissions_deleted(folder_a) - self._verify_permissions_deleted(file_1) - - def test_delete_permissions_deep_nested_structure( - self, project_object: Project, caplog: pytest.LogCaptureFixture - ) -> None: - """Test deleting permissions on a deeply nested structure.""" - project_object.store(synapse_client=self.syn) - self.schedule_for_cleanup(project_object.id) - - # GIVEN a deeply nested structure with permissions - structure = self.create_deep_nested_structure(project_object) - - # Set permissions on all entities - for entity in structure.values(): - self._set_custom_permissions(entity) - time.sleep(random.randint(10, 20)) - - # WHEN - Verify list_acl before deletion - self._verify_list_acl_functionality( - entity=structure["level_1"], - expected_entity_count=8, # all levels and files - recursive=True, - include_container_content=True, - log_tree=True, - ) - - # Verify tree logging occurred - assert "ACL Tree Structure:" in caplog.text - caplog.clear() - - # WHEN I delete permissions recursively from the top level - structure["level_1"].delete_permissions( - recursive=True, - include_container_content=True, - dry_run=False, - synapse_client=self.syn, - ) - - # THEN all permissions should be deleted - for entity in structure.values(): - self._verify_permissions_deleted(entity) - - def test_delete_permissions_wide_tree_structure( - self, project_object: Project, caplog: pytest.LogCaptureFixture - ) -> None: - """Test deleting permissions on a wide tree structure with multiple siblings.""" - project_object.store(synapse_client=self.syn) - self.schedule_for_cleanup(project_object.id) - - # GIVEN a wide tree structure with permissions - structure = self.create_wide_tree_structure(project_object) - folders = structure["folders"] - all_files = structure["all_files"] - root_file = structure["root_file"] - - # Set permissions on all entities - entities_to_set = folders + all_files + [root_file] - for entity in entities_to_set: - self._set_custom_permissions(entity) - time.sleep(random.randint(10, 20)) - - # WHEN - Verify list_acl before deletion - self._verify_list_acl_functionality( - entity=project_object, - expected_entity_count=7, # 3 folders + 3 files + 1 root file - recursive=True, - include_container_content=True, - log_tree=True, - ) - - # Verify tree logging occurred - assert "ACL Tree Structure:" in caplog.text - caplog.clear() - - # WHEN I delete permissions recursively from the project - project_object.delete_permissions( - recursive=True, - include_container_content=True, - dry_run=False, - synapse_client=self.syn, - ) - - # THEN all permissions should be deleted (except project which can't be deleted) - entities_to_verify = folders + all_files + [root_file] - for entity in entities_to_verify: - self._verify_permissions_deleted(entity) - - def test_delete_permissions_complex_mixed_structure( - self, project_object: Project, caplog: pytest.LogCaptureFixture - ) -> None: - """Test deleting permissions on a complex mixed structure.""" - project_object.store(synapse_client=self.syn) - self.schedule_for_cleanup(project_object.id) - - # GIVEN a complex mixed structure with permissions - structure = self.create_complex_mixed_structure(project_object) - - # Set permissions on all entities - entities_to_set = ( - [ - structure["shallow_folder"], - structure["shallow_file"], - structure["deep_branch"], - structure["sub_deep"], - structure["sub_sub_deep"], - structure["mixed_folder"], - ] - + structure["deep_files"] - + structure["mixed_sub_folders"] - + structure["mixed_files"] - ) - - for entity in entities_to_set: - self._set_custom_permissions(entity) - time.sleep(random.randint(10, 20)) - - # WHEN - Verify list_acl before deletion - self._verify_list_acl_functionality( - entity=project_object, - expected_entity_count=12, # complex structure with multiple entities - recursive=True, - include_container_content=True, - log_tree=True, - ) - - # Verify tree logging occurred - assert "ACL Tree Structure:" in caplog.text - caplog.clear() - - # WHEN I delete permissions recursively from the project - project_object.delete_permissions( - recursive=True, - include_container_content=True, - dry_run=False, - synapse_client=self.syn, - ) - - # THEN all permissions should be deleted - for entity in entities_to_set: - self._verify_permissions_deleted(entity) - - # Edge case tests - def test_delete_permissions_empty_folder( - self, project_object: Project, caplog: pytest.LogCaptureFixture - ) -> None: - """Test deleting permissions on an empty folder.""" - project_object.store(synapse_client=self.syn) - self.schedule_for_cleanup(project_object.id) - - # GIVEN an empty folder with custom permissions - empty_folder = Folder(name=f"empty_folder_{uuid.uuid4()}").store( - parent=project_object, synapse_client=self.syn - ) - self.schedule_for_cleanup(empty_folder.id) - self._set_custom_permissions(empty_folder) - time.sleep(random.randint(10, 20)) - - # WHEN - Verify list_acl before deletion (empty folder) - self._verify_list_acl_functionality( - entity=empty_folder, - expected_entity_count=1, # just the empty folder itself - recursive=True, - include_container_content=True, - log_tree=True, - ) - - # Verify tree logging occurred for empty structure - assert "ACL Tree Structure:" in caplog.text - caplog.clear() - - # WHEN I delete permissions recursively - empty_folder.delete_permissions( - recursive=True, - include_container_content=True, - dry_run=False, - synapse_client=self.syn, - ) - - # THEN the folder permissions should be deleted - self._verify_permissions_deleted(empty_folder) - - def test_delete_permissions_folder_with_only_files( - self, project_object: Project, caplog: pytest.LogCaptureFixture - ) -> None: - """Test deleting permissions on a folder that contains only files.""" - project_object.store(synapse_client=self.syn) - self.schedule_for_cleanup(project_object.id) - - # GIVEN a folder with only one file - folder = Folder(name=f"files_only_folder_{uuid.uuid4()}").store( - parent=project_object, synapse_client=self.syn - ) - self.schedule_for_cleanup(folder.id) - - file = File( - path=utils.make_bogus_uuid_file(), name=f"only_file_{uuid.uuid4()}" - ).store(parent=folder, synapse_client=self.syn) - self.schedule_for_cleanup(file.id) - - # Set permissions on all entities - self._set_custom_permissions(folder) - self._set_custom_permissions(file) - time.sleep(random.randint(10, 20)) - - # WHEN - Verify list_acl before deletion - self._verify_list_acl_functionality( - entity=folder, - expected_entity_count=2, # folder and file - recursive=True, - include_container_content=True, - log_tree=True, - ) - - # Verify tree logging occurred - assert "ACL Tree Structure:" in caplog.text - caplog.clear() - - # WHEN I delete permissions recursively - folder.delete_permissions( - recursive=True, - include_container_content=True, - dry_run=False, - synapse_client=self.syn, - ) - - # THEN all permissions should be deleted - self._verify_permissions_deleted(folder) - self._verify_permissions_deleted(file) - - def test_delete_permissions_folder_with_only_folders( - self, project_object: Project, caplog: pytest.LogCaptureFixture - ) -> None: - """Test deleting permissions on a folder that contains only sub-folders.""" - project_object.store(synapse_client=self.syn) - self.schedule_for_cleanup(project_object.id) - - # GIVEN a folder with only sub-folders - parent_folder = Folder(name=f"folders_only_parent_{uuid.uuid4()}").store( - parent=project_object, synapse_client=self.syn - ) - self.schedule_for_cleanup(parent_folder.id) - - # Create sub-folders in parallel - sub_folders = [ - Folder(name=f"only_subfolder_{i}_{uuid.uuid4()}").store( - parent=parent_folder, synapse_client=self.syn - ) - for i in range(3) - ] - - # Schedule cleanup for sub-folders - for sub_folder in sub_folders: - self.schedule_for_cleanup(sub_folder.id) - - # Set permissions on all entities - entities_to_set = [parent_folder] + sub_folders - for entity in entities_to_set: - self._set_custom_permissions(entity) - time.sleep(random.randint(10, 20)) - - # WHEN - Verify list_acl before deletion - self._verify_list_acl_functionality( - entity=parent_folder, - expected_entity_count=4, # parent + 3 sub-folders - recursive=True, - include_container_content=True, - log_tree=True, - ) - - # Verify tree logging occurred - assert "ACL Tree Structure:" in caplog.text - caplog.clear() - - # WHEN I delete permissions recursively - parent_folder.delete_permissions( - recursive=True, - include_container_content=True, - dry_run=False, - synapse_client=self.syn, - ) - - # THEN all permissions should be deleted - for entity in entities_to_set: - self._verify_permissions_deleted(entity) - - def test_delete_permissions_target_files_only_complex( - self, project_object: Project, caplog: pytest.LogCaptureFixture - ) -> None: - """Test deleting permissions targeting only files in a complex structure.""" - project_object.store(synapse_client=self.syn) - self.schedule_for_cleanup(project_object.id) - - # GIVEN a complex structure with permissions - structure = self.create_complex_mixed_structure(project_object) - - # Set permissions on all entities - self._set_custom_permissions(structure["shallow_folder"]) - self._set_custom_permissions(structure["shallow_file"]) - self._set_custom_permissions(structure["deep_branch"]) - self._set_custom_permissions(structure["sub_deep"]) - for file in structure["deep_files"]: - self._set_custom_permissions(file) - time.sleep(random.randint(10, 20)) - - # WHEN - Verify list_acl with target_entity_types for files only - self._verify_list_acl_functionality( - entity=project_object, - expected_entity_count=4, # shallow_file + 3 deep_files - recursive=True, - include_container_content=True, - target_entity_types=["file"], - log_tree=True, - ) - - # Verify tree logging occurred with file filtering - assert "ACL Tree Structure:" in caplog.text - caplog.clear() - - # WHEN I delete permissions targeting only files - project_object.delete_permissions( - recursive=True, - include_container_content=True, - target_entity_types=["file"], - dry_run=False, - synapse_client=self.syn, - ) - - # THEN only file permissions should be deleted - self._verify_permissions_deleted(structure["shallow_file"]) - for file in structure["deep_files"]: - self._verify_permissions_deleted(file) - - # BUT folder permissions should remain - assert self._verify_permissions_not_deleted(structure["shallow_folder"]) - assert self._verify_permissions_not_deleted(structure["deep_branch"]) - assert self._verify_permissions_not_deleted(structure["sub_deep"]) - - # Include container content vs recursive tests - def test_delete_permissions_include_container_only_deep_structure( - self, project_object: Project, caplog: pytest.LogCaptureFixture - ) -> None: - """Test include_container_content=True without recursive on deep structure.""" - project_object.store(synapse_client=self.syn) - self.schedule_for_cleanup(project_object.id) - - # GIVEN a deep nested structure with permissions - structure = self.create_deep_nested_structure(project_object) - - # Set permissions on all entities - for entity in structure.values(): - self._set_custom_permissions(entity) - time.sleep(random.randint(10, 20)) - - # WHEN - Verify list_acl with include_container_content=True - self._verify_list_acl_functionality( - entity=structure["level_1"], - expected_entity_count=3, # level_1, file_at_1, level_2 (non-recursive) - recursive=False, - include_container_content=True, - log_tree=True, - ) - - # Verify tree logging occurred - assert "ACL Tree Structure:" in caplog.text - caplog.clear() - - # WHEN I delete permissions with include_container_content=True but recursive=False - structure["level_1"].delete_permissions( - include_self=True, - include_container_content=True, - recursive=False, - dry_run=False, - synapse_client=self.syn, - ) - - # THEN only level_1 and its direct children should have permissions deleted - self._verify_permissions_deleted(structure["level_1"]) - self._verify_permissions_deleted(structure["file_at_1"]) - self._verify_permissions_deleted(structure["level_2"]) - - # BUT deeper nested entities should retain permissions - self._verify_permissions_not_deleted(structure["level_3"]) - self._verify_permissions_not_deleted(structure["level_4"]) - self._verify_permissions_not_deleted(structure["file_at_2"]) - self._verify_permissions_not_deleted(structure["file_at_3"]) - self._verify_permissions_not_deleted(structure["file_at_4"]) - - def test_delete_permissions_skip_self_complex_structure( - self, project_object: Project, caplog: pytest.LogCaptureFixture - ) -> None: - """Test include_self=False on a complex structure.""" - project_object.store(synapse_client=self.syn) - self.schedule_for_cleanup(project_object.id) - - # GIVEN a complex mixed structure with permissions - structure = self.create_complex_mixed_structure(project_object) - - # Set permissions on all entities - self._set_custom_permissions(structure["mixed_folder"]) - for folder in structure["mixed_sub_folders"]: - self._set_custom_permissions(folder) - for file in structure["mixed_files"]: - self._set_custom_permissions(file) - time.sleep(random.randint(10, 20)) - - # WHEN - Verify list_acl before deletion (should show all entities) - self._verify_list_acl_functionality( - entity=structure["mixed_folder"], - expected_entity_count=4, # mixed_folder + 2 sub_folders + 1 mixed_file - recursive=True, - include_container_content=True, - log_tree=True, - ) - - # Verify tree logging occurred - assert "ACL Tree Structure:" in caplog.text - caplog.clear() - - # WHEN I delete permissions with include_self=False - structure["mixed_folder"].delete_permissions( - include_self=False, - include_container_content=True, - recursive=True, - dry_run=False, - synapse_client=self.syn, - ) - - # THEN the mixed_folder permissions should remain - self._verify_permissions_not_deleted(structure["mixed_folder"]) - - # BUT child permissions should be deleted - for folder in structure["mixed_sub_folders"]: - self._verify_permissions_deleted(folder) - - for file in structure["mixed_files"]: - self._verify_permissions_deleted(file) - - # Dry run functionality tests - - def test_delete_permissions_dry_run_no_changes( - self, project_object: Project, caplog: pytest.LogCaptureFixture - ) -> None: - """Test that dry_run=True makes no actual changes.""" - project_object.store(synapse_client=self.syn) - self.schedule_for_cleanup(project_object.id) - - # GIVEN a simple structure with permissions - structure = self.create_simple_tree_structure(project_object) - folder_a = structure["folder_a"] - file_1 = structure["file_1"] - - # Set permissions on all entities - self._set_custom_permissions(folder_a) - self._set_custom_permissions(file_1) - time.sleep(random.randint(10, 20)) - - # WHEN - Verify list_acl before dry run - initial_acl_result = self._verify_list_acl_functionality( - entity=folder_a, - expected_entity_count=2, # folder_a and file_1 - recursive=True, - include_container_content=True, - log_tree=True, - ) - - # Verify tree logging occurred - assert "ACL Tree Structure:" in caplog.text - caplog.clear() - - # WHEN I run delete_permissions with dry_run=True - folder_a.delete_permissions( - recursive=True, - include_container_content=True, - dry_run=True, - synapse_client=self.syn_with_logger, - ) - - # THEN no permissions should be deleted - self._verify_permissions_not_deleted(folder_a) - self._verify_permissions_not_deleted(file_1) - - # AND dry run messages should be logged - self._verify_log_messages( - caplog, - list_acl_called=False, - delete_permissions_called=True, - dry_run=True, - tree_logging=False, - ) - - # WHEN - Verify list_acl after dry run (should be identical) - caplog.clear() - final_acl_result = self._verify_list_acl_functionality( - entity=folder_a, - expected_entity_count=2, # should be same as before - recursive=True, - include_container_content=True, - log_tree=True, - ) - - # Results should be identical since dry run made no changes - assert len(initial_acl_result.all_entity_acls) == len( - final_acl_result.all_entity_acls - ) - - def test_delete_permissions_dry_run_complex_logging( - self, project_object: Project, caplog: pytest.LogCaptureFixture - ) -> None: - """Test dry run logging for complex structures.""" - project_object.store(synapse_client=self.syn) - self.schedule_for_cleanup(project_object.id) - - # GIVEN a complex structure with permissions - structure = self.create_complex_mixed_structure(project_object) - - # Set permissions on a subset of entities - self._set_custom_permissions(structure["deep_branch"]), - self._set_custom_permissions(structure["sub_deep"]), - self._set_custom_permissions(structure["deep_files"][0]), - time.sleep(random.randint(10, 20)) - - # WHEN - Verify list_acl with detailed logging before dry run - self._verify_list_acl_functionality( - entity=structure["deep_branch"], - expected_entity_count=3, # deep_branch, sub_deep, and one deep_file - recursive=True, - include_container_content=True, - log_tree=True, - ) - - # Verify tree logging occurred - assert "ACL Tree Structure:" in caplog.text - caplog.clear() - - # WHEN I run delete_permissions with dry_run=True - structure["deep_branch"].delete_permissions( - recursive=True, - include_container_content=True, - dry_run=True, - show_acl_details=True, - show_files_in_containers=True, - synapse_client=self.syn_with_logger, - ) - - # THEN no permissions should be deleted - self._verify_permissions_not_deleted(structure["deep_branch"]), - self._verify_permissions_not_deleted(structure["sub_deep"]), - self._verify_permissions_not_deleted(structure["deep_files"][0]), - - # AND comprehensive dry run analysis should be logged - self._verify_log_messages( - caplog, - list_acl_called=False, - delete_permissions_called=True, - dry_run=True, - tree_logging=False, - ) - - # Verify specific detailed logging messages - assert "DRY RUN: Permission Deletion Impact Analysis" in caplog.text - assert "End of Dry Run Analysis" in caplog.text - - # Performance and stress tests - def test_delete_permissions_large_flat_structure( - self, project_object: Project, caplog: pytest.LogCaptureFixture - ) -> None: - """Test deleting permissions on a large flat structure.""" - project_object.store(synapse_client=self.syn) - self.schedule_for_cleanup(project_object.id) - - # GIVEN a folder with many files - large_folder = Folder(name=f"large_folder_{uuid.uuid4()}").store( - parent=project_object, synapse_client=self.syn - ) - self.schedule_for_cleanup(large_folder.id) - - # Create files in parallel - files = [ - File( - path=utils.make_bogus_uuid_file(), name=f"large_file_{i}_{uuid.uuid4()}" - ).store(parent=large_folder, synapse_client=self.syn) - for i in range(10) # Reduced from larger number for test performance - ] - - # Schedule cleanup for files - for file in files: - self.schedule_for_cleanup(file.id) - - # Set permissions on all entities - entities_to_set = [large_folder] + files - for entity in entities_to_set: - self._set_custom_permissions(entity) - time.sleep(random.randint(10, 20)) - - # WHEN - Verify list_acl performance with large structure - self._verify_list_acl_functionality( - entity=large_folder, - expected_entity_count=11, # large_folder + 10 files - recursive=True, - include_container_content=True, - log_tree=True, - ) - - # Verify tree logging occurred for large structure - assert "ACL Tree Structure:" in caplog.text - caplog.clear() - - # WHEN I delete permissions recursively - large_folder.delete_permissions( - recursive=True, - include_container_content=True, - dry_run=False, - synapse_client=self.syn, - ) - - # THEN all permissions should be deleted - for entity in entities_to_set: - self._verify_permissions_deleted(entity) - - def test_delete_permissions_multiple_nested_branches( - self, project_object: Project, caplog: pytest.LogCaptureFixture - ) -> None: - """Test deleting permissions on multiple nested branches simultaneously.""" - project_object.store(synapse_client=self.syn) - self.schedule_for_cleanup(project_object.id) - - # GIVEN multiple complex nested branches - branches = [ - Folder(name=f"branch_{branch_name}_{uuid.uuid4()}").store( - parent=project_object, synapse_client=self.syn - ) - for branch_name in ["alpha", "beta"] - ] - - all_entities = list(branches) - - # Schedule cleanup for branches - for branch in branches: - self.schedule_for_cleanup(branch.id) - - # Create nested structure in each branch in parallel - nested_folders = [] - for branch_name, branch in zip(["alpha", "beta"], branches): - current_parent = branch - for level in range(2): - # Create sub-folder and file tasks for this level - sub_folder_task = Folder( - name=f"{branch_name}_level_{level}_{uuid.uuid4()}" - ).store(parent=current_parent, synapse_client=self.syn) - - nested_folders.append(sub_folder_task) - - # Add nested folders to all_entities and schedule cleanup - all_entities.extend(nested_folders) - for folder in nested_folders: - self.schedule_for_cleanup(folder.id) - - # Now create files for each nested folder in parallel - files = [] - folder_index = 0 - for branch_name in ["alpha", "beta"]: - for level in range(2): - parent_folder = nested_folders[folder_index] - file_task = File( - path=utils.make_bogus_uuid_file(), - name=f"{branch_name}_file_{level}_{uuid.uuid4()}", - ).store(parent=parent_folder, synapse_client=self.syn) - files.append(file_task) - folder_index += 1 - - # Add files to all_entities and schedule cleanup - all_entities.extend(files) - for file in files: - self.schedule_for_cleanup(file.id) - - # Set permissions on all entities - for entity in all_entities: - self._set_custom_permissions(entity) - time.sleep(random.randint(10, 20)) - - # WHEN - Verify list_acl before deletion (complex multiple branches) - self._verify_list_acl_functionality( - entity=project_object, - expected_entity_count=11, - recursive=True, - include_container_content=True, - log_tree=True, - ) - - # Verify tree logging occurred for multiple branches - assert "ACL Tree Structure:" in caplog.text - caplog.clear() - - # WHEN I delete permissions recursively from the project - project_object.delete_permissions( - recursive=True, - include_container_content=True, - dry_run=False, - synapse_client=self.syn, - ) - - # THEN all permissions should be deleted - for entity in all_entities: - self._verify_permissions_deleted(entity) - - def test_delete_permissions_selective_branches( - self, project_object: Project, caplog: pytest.LogCaptureFixture - ) -> None: - """Test selectively deleting permissions from specific branches.""" - project_object.store(synapse_client=self.syn) - self.schedule_for_cleanup(project_object.id) - - # GIVEN multiple branches with permissions - # Create branches - branch_a = Folder(name=f"branch_a_{uuid.uuid4()}").store( - parent=project_object, synapse_client=self.syn - ) - branch_b = Folder(name=f"branch_b_{uuid.uuid4()}").store( - parent=project_object, synapse_client=self.syn - ) - - # Schedule cleanup for branches - self.schedule_for_cleanup(branch_a.id) - self.schedule_for_cleanup(branch_b.id) - - # Create files in each branch - file_a = File( - path=utils.make_bogus_uuid_file(), name=f"file_a_{uuid.uuid4()}" - ).store(parent=branch_a, synapse_client=self.syn) - file_b = File( - path=utils.make_bogus_uuid_file(), name=f"file_b_{uuid.uuid4()}" - ).store(parent=branch_b, synapse_client=self.syn) - - # Schedule cleanup for files - self.schedule_for_cleanup(file_a.id) - self.schedule_for_cleanup(file_b.id) - - # Set permissions on all entities - self._set_custom_permissions(branch_a) - self._set_custom_permissions(branch_b) - self._set_custom_permissions(file_a) - self._set_custom_permissions(file_b) - time.sleep(random.randint(10, 20)) - - # WHEN - Verify list_acl before selective deletion - self._verify_list_acl_functionality( - entity=branch_a, - expected_entity_count=2, # branch_a and file_a - recursive=True, - include_container_content=True, - log_tree=True, - ) - - # Verify tree logging occurred for selective branch - assert "ACL Tree Structure:" in caplog.text - caplog.clear() - - # WHEN I delete permissions only from branch_a - branch_a.delete_permissions( - recursive=True, - include_container_content=True, - dry_run=False, - synapse_client=self.syn, - ) - - # THEN only branch_a and its contents should have permissions deleted - self._verify_permissions_deleted(branch_a) - self._verify_permissions_deleted(file_a) - - # BUT branch_b should retain permissions - self._verify_permissions_not_deleted(branch_b) - self._verify_permissions_not_deleted(file_b) - - def test_delete_permissions_mixed_entity_types_in_structure( - self, project_object: Project, caplog: pytest.LogCaptureFixture - ) -> None: - """Test deleting permissions with mixed entity types in complex structure.""" - project_object.store(synapse_client=self.syn) - self.schedule_for_cleanup(project_object.id) - - # GIVEN a structure with both files and folders at multiple levels - structure = self.create_complex_mixed_structure(project_object) - - # Set permissions on a mix of entities - self._set_custom_permissions(structure["shallow_folder"]), - self._set_custom_permissions(structure["shallow_file"]), - self._set_custom_permissions(structure["deep_branch"]), - self._set_custom_permissions(structure["deep_files"][1]), - self._set_custom_permissions(structure["mixed_sub_folders"][0]), - time.sleep(random.randint(10, 20)) - - # WHEN - Verify list_acl with mixed entity types - self._verify_list_acl_functionality( - entity=project_object, - expected_entity_count=5, # All the entities we set permissions on - recursive=True, - include_container_content=True, - target_entity_types=["file", "folder"], - log_tree=True, - ) - - # Verify tree logging occurred for mixed entity types - assert "ACL Tree Structure:" in caplog.text - caplog.clear() - - # WHEN I delete permissions targeting both files and folders - project_object.delete_permissions( - recursive=True, - include_container_content=True, - target_entity_types=["file", "folder"], - dry_run=False, - synapse_client=self.syn, - ) - - # THEN all targeted entities should have permissions deleted - self._verify_permissions_deleted(structure["shallow_folder"]), - self._verify_permissions_deleted(structure["shallow_file"]), - self._verify_permissions_deleted(structure["deep_branch"]), - self._verify_permissions_deleted(structure["deep_files"][1]), - self._verify_permissions_deleted(structure["mixed_sub_folders"][0]), - - def test_delete_permissions_no_container_content_but_has_children( - self, project_object: Project, caplog: pytest.LogCaptureFixture - ) -> None: - """Test deleting permissions without include_container_content when children exist.""" - project_object.store(synapse_client=self.syn) - self.schedule_for_cleanup(project_object.id) - - # GIVEN a folder with children and custom permissions - parent_folder = Folder(name=f"parent_folder_{uuid.uuid4()}").store( - parent=project_object, synapse_client=self.syn - ) - self.schedule_for_cleanup(parent_folder.id) - - child_file = File( - path=utils.make_bogus_uuid_file(), name=f"child_file_{uuid.uuid4()}" - ).store(parent=parent_folder, synapse_client=self.syn) - self.schedule_for_cleanup(child_file.id) - - # Set permissions on both entities - self._set_custom_permissions(parent_folder) - self._set_custom_permissions(child_file) - time.sleep(random.randint(10, 20)) - - # WHEN - Verify list_acl before testing container content exclusion - self._verify_list_acl_functionality( - entity=parent_folder, - expected_entity_count=1, # Only parent_folder, child excluded due to include_container_content=False - recursive=False, - include_container_content=False, - log_tree=True, - ) - - # Verify tree logging occurred (should show limited structure) - assert "ACL Tree Structure:" in caplog.text - caplog.clear() - - # WHEN I delete permissions without include_container_content - parent_folder.delete_permissions( - include_self=True, - include_container_content=False, - dry_run=False, - synapse_client=self.syn, - ) - - # THEN only parent permissions should be deleted - self._verify_permissions_deleted(parent_folder) - - # AND child permissions should remain - self._verify_permissions_not_deleted(child_file) - - def test_delete_permissions_case_insensitive_entity_types( - self, project_object: Project, caplog: pytest.LogCaptureFixture - ) -> None: - """Test that target_entity_types are case-insensitive.""" - project_object.store(synapse_client=self.syn) - self.schedule_for_cleanup(project_object.id) - - # GIVEN a simple structure with permissions - structure = self.create_simple_tree_structure(project_object) - folder_a = structure["folder_a"] - file_1 = structure["file_1"] - - # Set permissions on all entities - self._set_custom_permissions(folder_a) - self._set_custom_permissions(file_1) - time.sleep(random.randint(10, 20)) - - # WHEN - Verify list_acl with case-insensitive entity types - self._verify_list_acl_functionality( - entity=folder_a, - expected_entity_count=2, # folder_a and file_1 (case-insensitive filtering) - recursive=True, - include_container_content=True, - target_entity_types=[ - "FOLDER", - "file", - ], # Mixed case to test case-insensitivity - log_tree=True, - ) - - # Verify tree logging occurred with case-insensitive filtering - assert "ACL Tree Structure:" in caplog.text - caplog.clear() - - # WHEN I delete permissions using mixed case entity types - folder_a.delete_permissions( - recursive=True, - include_container_content=True, - target_entity_types=["FOLDER", "file"], # Mixed case - dry_run=False, - synapse_client=self.syn, - ) - - # THEN all permissions should be deleted - self._verify_permissions_deleted(folder_a) - self._verify_permissions_deleted(file_1) - - -class TestAllEntityTypesPermissions: - """Test permissions functionality across all supported entity types.""" - - @pytest.fixture(autouse=True, scope="function") - def init( - self, - syn: Synapse, - schedule_for_cleanup: Callable[..., None], - ) -> None: - self.syn = syn - self.schedule_for_cleanup = schedule_for_cleanup - - def create_all_entity_types(self, project_model: Project) -> Dict[str, any]: - """Create all supported entity types for testing.""" - project_model = project_model.store(synapse_client=self.syn) - self.schedule_for_cleanup(project_model.id) - - entities = {"project": project_model} - - file_path = utils.make_bogus_uuid_file() - self.schedule_for_cleanup(file_path) - file_entity = File( - name=f"test_file_{str(uuid.uuid4())}.txt", - parent_id=project_model.id, - path=file_path, - ) - file_entity = file_entity.store(synapse_client=self.syn) - self.schedule_for_cleanup(file_entity.id) - entities["file"] = file_entity - - folder_entity = Folder( - name=f"test_folder_{str(uuid.uuid4())}", - parent_id=project_model.id, - ) - folder_entity = folder_entity.store(synapse_client=self.syn) - self.schedule_for_cleanup(folder_entity.id) - entities["folder"] = folder_entity - - table_entity = Table( - name=f"test_table_{str(uuid.uuid4())}", - parent_id=project_model.id, - columns=[ - Column(name="test_column", column_type=ColumnType.STRING), - Column(name="test_column2", column_type=ColumnType.INTEGER), - ], - ) - table_entity = table_entity.store(synapse_client=self.syn) - self.schedule_for_cleanup(table_entity.id) - entities["table"] = table_entity - - entityview_entity = EntityView( - name=f"test_entityview_{str(uuid.uuid4())}", - parent_id=project_model.id, - scope_ids=[project_model.id], - view_type_mask=ViewTypeMask.FILE, - ) - entityview_entity = entityview_entity.store(synapse_client=self.syn) - self.schedule_for_cleanup(entityview_entity.id) - entities["entityview"] = entityview_entity - - materializedview_entity = MaterializedView( - name=f"test_materializedview_{str(uuid.uuid4())}", - parent_id=project_model.id, - defining_sql=f"SELECT * FROM {table_entity.id}", - ) - materializedview_entity = materializedview_entity.store(synapse_client=self.syn) - self.schedule_for_cleanup(materializedview_entity.id) - entities["materializedview"] = materializedview_entity - - virtualtable_entity = VirtualTable( - name=f"test_virtualtable_{str(uuid.uuid4())}", - parent_id=project_model.id, - defining_sql=f"SELECT * FROM {table_entity.id}", - ) - virtualtable_entity = virtualtable_entity.store(synapse_client=self.syn) - self.schedule_for_cleanup(virtualtable_entity.id) - entities["virtualtable"] = virtualtable_entity - - dataset_entity = Dataset( - name=f"test_dataset_{str(uuid.uuid4())}", - parent_id=project_model.id, - items=[EntityRef(id=file_entity.id, version=1)], - ) - dataset_entity = dataset_entity.store(synapse_client=self.syn) - self.schedule_for_cleanup(dataset_entity.id) - entities["dataset"] = dataset_entity - - datasetcollection_entity = DatasetCollection( - name=f"test_datasetcollection_{str(uuid.uuid4())}", - parent_id=project_model.id, - items=[EntityRef(id=dataset_entity.id, version=1)], - ) - datasetcollection_entity = datasetcollection_entity.store( - synapse_client=self.syn - ) - self.schedule_for_cleanup(datasetcollection_entity.id) - entities["datasetcollection"] = datasetcollection_entity - - return entities - - def create_all_entity_types_with_acl( - self, project_model: Project - ) -> Dict[str, any]: - """Create all entity types with local ACL permissions for testing.""" - entities = self.create_all_entity_types(project_model) - - for entity_type, entity in entities.items(): - if entity_type != "project": - entity.set_permissions( - principal_id=AUTHENTICATED_USERS, - access_type=["READ"], - synapse_client=self.syn, - ) - - time.sleep(10) - return entities - - def test_list_acl_all_entity_types(self) -> None: - """Test list_acl functionality with all supported entity types.""" - # GIVEN a project with all supported entity types and local ACL permissions - project = Project(name=f"test_project_{uuid.uuid4()}") - entities = self.create_all_entity_types_with_acl(project) - - # WHEN I call list_acl on the project with all entity types - result = entities["project"].list_acl( - recursive=True, - include_container_content=True, - target_entity_types=[ - "file", - "folder", - "table", - "entityview", - "materializedview", - "virtualtable", - "dataset", - "datasetcollection", - ], - synapse_client=self.syn, - ) - - # THEN the result should contain ACLs for all entity types - assert isinstance(result, AclListResult) - assert len(result.all_entity_acls) >= 1 - - entity_ids = [acl.entity_id for acl in result.all_entity_acls] - assert entities["project"].id in entity_ids - - # AND verify AclListResult structure and content - entities_with_read_permissions = [] - for entity_acl in result.all_entity_acls: - assert isinstance(entity_acl.entity_id, str) - assert entity_acl.entity_id.startswith("syn") - assert len(entity_acl.acl_entries) >= 0 - - # Check if this entity has AUTHENTICATED_USERS with READ permissions - for acl_entry in entity_acl.acl_entries: - assert isinstance(acl_entry.principal_id, str) - assert isinstance(acl_entry.permissions, list) - if ( - acl_entry.principal_id == str(AUTHENTICATED_USERS) - and "READ" in acl_entry.permissions - ): - entities_with_read_permissions.append(entity_acl.entity_id) - - # AND each entity should have the correct AUTHENTICATED_USERS permissions - for entity_type, entity in entities.items(): - if entity_type != "project": - individual_acl = entity.get_acl( - principal_id=AUTHENTICATED_USERS, synapse_client=self.syn - ) - assert individual_acl is not None - assert ( - "READ" in individual_acl - ), f"AUTHENTICATED_USERS should have READ access on {entity_type} {entity.id}" - - # Verify this entity appears in the AclListResult with READ permissions - assert ( - entity.id in entities_with_read_permissions - ), f"Entity {entity.id} ({entity_type}) should appear in AclListResult with READ permissions" - - def test_list_acl_specific_entity_types(self) -> None: - """Test list_acl functionality with specific entity types.""" - # GIVEN a project with all supported entity types - project = Project(name=f"test_project_{uuid.uuid4()}") - entities = self.create_all_entity_types_with_acl(project) - - # WHEN I call list_acl with only table-related entity types - result = entities["project"].list_acl( - recursive=True, - include_container_content=True, - target_entity_types=[ - "table", - "entityview", - "materializedview", - "virtualtable", - ], - synapse_client=self.syn, - ) - - # THEN the result should be valid - assert isinstance(result, AclListResult) - assert len(result.all_entity_acls) >= 1 - - # AND verify only table-related entities are included - returned_entity_ids = [acl.entity_id for acl in result.all_entity_acls] - expected_table_entity_ids = [ - entities["table"].id, - entities["entityview"].id, - entities["materializedview"].id, - entities["virtualtable"].id, - ] - - # Check that all expected table entities are present in the result - for entity_id in expected_table_entity_ids: - if entity_id in returned_entity_ids: - # Find the corresponding EntityAcl - entity_acl = next( - acl for acl in result.all_entity_acls if acl.entity_id == entity_id - ) - assert isinstance(entity_acl.entity_id, str) - assert entity_acl.entity_id.startswith("syn") - assert len(entity_acl.acl_entries) >= 0 - - # Verify AUTHENTICATED_USERS has READ permissions - has_read_permission = False - for acl_entry in entity_acl.acl_entries: - if ( - acl_entry.principal_id == str(AUTHENTICATED_USERS) - and "READ" in acl_entry.permissions - ): - has_read_permission = True - break - assert ( - has_read_permission - ), f"Entity {entity_id} should have READ permissions for AUTHENTICATED_USERS" - - # WHEN I call list_acl with only dataset-related entity types - result_datasets = entities["project"].list_acl( - recursive=True, - include_container_content=True, - target_entity_types=["dataset", "datasetcollection"], - synapse_client=self.syn, - ) - - # THEN the result should be valid - assert isinstance(result_datasets, AclListResult) - assert len(result_datasets.all_entity_acls) >= 1 - - # AND verify only dataset-related entities are included - returned_dataset_ids = [ - acl.entity_id for acl in result_datasets.all_entity_acls - ] - expected_dataset_entity_ids = [ - entities["dataset"].id, - entities["datasetcollection"].id, - ] - - # AND Check that all expected dataset entities are present in the result - for entity_id in expected_dataset_entity_ids: - if entity_id in returned_dataset_ids: - # Find the corresponding EntityAcl - entity_acl = next( - acl - for acl in result_datasets.all_entity_acls - if acl.entity_id == entity_id - ) - assert isinstance(entity_acl.entity_id, str) - assert entity_acl.entity_id.startswith("syn") - assert len(entity_acl.acl_entries) >= 0 - - # Verify AUTHENTICATED_USERS has READ permissions - has_read_permission = False - for acl_entry in entity_acl.acl_entries: - if ( - acl_entry.principal_id == str(AUTHENTICATED_USERS) - and "READ" in acl_entry.permissions - ): - has_read_permission = True - break - assert ( - has_read_permission - ), f"Entity {entity_id} should have READ permissions for AUTHENTICATED_USERS" - - def test_delete_permissions_all_entity_types(self) -> None: - """Test delete_permissions functionality with all supported entity types.""" - # GIVEN a project with all supported entity types and local ACL permissions - project = Project(name=f"test_project_{uuid.uuid4()}") - entities = self.create_all_entity_types_with_acl(project) - - # AND I verify AUTHENTICATED_USERS has READ permissions before deletion - for entity_type, entity in entities.items(): - if entity_type != "project": - acl_before = entity.get_acl( - principal_id=AUTHENTICATED_USERS, synapse_client=self.syn - ) - assert ( - "READ" in acl_before - ), f"AUTHENTICATED_USERS should have READ access on {entity_type} before deletion" - - # WHEN I call delete_permissions with dry_run=True to test functionality - entities["project"].delete_permissions( - recursive=True, - include_container_content=True, - target_entity_types=[ - "file", - "folder", - "table", - "entityview", - "materializedview", - "virtualtable", - "dataset", - "datasetcollection", - ], - dry_run=True, - synapse_client=self.syn, - ) - - # THEN no exception should be raised - # AND permissions should still exist after dry run - for entity_type, entity in entities.items(): - if entity_type != "project": - acl_after = entity.get_acl( - principal_id=AUTHENTICATED_USERS, synapse_client=self.syn - ) - assert ( - "READ" in acl_after - ), f"AUTHENTICATED_USERS should still have READ access on {entity_type} after dry run" - - def test_delete_permissions_all_entity_types_actual_deletion(self) -> None: - """Test delete_permissions functionality with actual deletion (dry_run=False).""" - # GIVEN a project with all supported entity types and local ACL permissions - project = Project(name=f"test_project_{uuid.uuid4()}") - entities = self.create_all_entity_types_with_acl(project) - - # AND I verify AUTHENTICATED_USERS has READ permissions before deletion - initial_acl_result = entities["project"].list_acl( - recursive=True, - include_container_content=True, - target_entity_types=[ - "file", - "folder", - "table", - "entityview", - "materializedview", - "virtualtable", - "dataset", - "datasetcollection", - ], - synapse_client=self.syn, - ) - - # Verify structure and content before deletion - assert isinstance(initial_acl_result, AclListResult) - entities_with_permissions_before = set() - for entity_acl in initial_acl_result.all_entity_acls: - for acl_entry in entity_acl.acl_entries: - if ( - acl_entry.principal_id == str(AUTHENTICATED_USERS) - and "READ" in acl_entry.permissions - ): - entities_with_permissions_before.add(entity_acl.entity_id) - - assert ( - len(entities_with_permissions_before) > 0 - ), "Should have entities with READ permissions before deletion" - - # WHEN I call delete_permissions with dry_run=False for actual deletion - entities["project"].delete_permissions( - recursive=True, - include_container_content=True, - target_entity_types=[ - "file", - "folder", - "table", - "entityview", - "materializedview", - "virtualtable", - "dataset", - "datasetcollection", - ], - dry_run=False, - synapse_client=self.syn, - ) - - # THEN permissions should be actually deleted (inheritance restored) - final_acl_result = entities["project"].list_acl( - recursive=True, - include_container_content=True, - target_entity_types=[ - "file", - "folder", - "table", - "entityview", - "materializedview", - "virtualtable", - "dataset", - "datasetcollection", - ], - synapse_client=self.syn, - ) - - # AND Verify that local ACL permissions have been removed - entities_with_permissions_after = set() - for entity_acl in final_acl_result.all_entity_acls: - for acl_entry in entity_acl.acl_entries: - if ( - acl_entry.principal_id == str(AUTHENTICATED_USERS) - and "READ" in acl_entry.permissions - ): - entities_with_permissions_after.add(entity_acl.entity_id) - - # AND Should have fewer entities with local ACL permissions after deletion - for entity_type, entity in entities.items(): - if entity_type != "project": - acl_after = entity.get_acl( - principal_id=AUTHENTICATED_USERS, - synapse_client=self.syn, - check_benefactor=False, - ) - assert not acl_after, "Local ACL should be removed" - - def test_mixed_case_entity_types_actual_deletion(self) -> None: - """Test that entity types are case-insensitive with actual deletion.""" - # GIVEN a project with all supported entity types - project = Project(name=f"test_project_{uuid.uuid4()}") - entities = self.create_all_entity_types_with_acl(project) - - # AND I verify initial ACL state - initial_result = entities["project"].list_acl( - recursive=True, - include_container_content=True, - target_entity_types=["FILE", "Folder", "TABLE"], - synapse_client=self.syn, - ) - - assert isinstance(initial_result, AclListResult) - initial_entities_with_permissions = set() - for entity_acl in initial_result.all_entity_acls: - for acl_entry in entity_acl.acl_entries: - if ( - acl_entry.principal_id == str(AUTHENTICATED_USERS) - and "READ" in acl_entry.permissions - ): - initial_entities_with_permissions.add(entity_acl.entity_id) - - assert ( - len(initial_entities_with_permissions) > 0 - ), "Should have entities with READ permissions before deletion" - - # WHEN I call delete_permissions with mixed case entity types and dry_run=False - entities["project"].delete_permissions( - recursive=True, - include_container_content=True, - target_entity_types=["FILE", "Folder", "TABLE"], - dry_run=False, - synapse_client=self.syn, - ) - - # THEN verify that permissions were actually deleted - final_result = entities["project"].list_acl( - recursive=True, - include_container_content=True, - target_entity_types=["FILE", "Folder", "TABLE"], - synapse_client=self.syn, - ) - - assert isinstance(final_result, AclListResult) - final_entities_with_permissions = set() - for entity_acl in final_result.all_entity_acls: - for acl_entry in entity_acl.acl_entries: - if ( - acl_entry.principal_id == str(AUTHENTICATED_USERS) - and "READ" in acl_entry.permissions - ): - final_entities_with_permissions.add(entity_acl.entity_id) - - # AND Verify individual entity permissions were removed/restored to inheritance - for entity_type in ["file", "folder", "table"]: - entity = entities[entity_type] - acl_after = entity.get_acl( - principal_id=AUTHENTICATED_USERS, synapse_client=self.syn - ) - assert ( - not acl_after - ), f"Local ACL for {entity_type} {entity.id} should be removed after deletion" diff --git a/tests/integration/synapseclient/models/synchronous/test_project.py b/tests/integration/synapseclient/models/synchronous/test_project.py deleted file mode 100644 index b6649739c..000000000 --- a/tests/integration/synapseclient/models/synchronous/test_project.py +++ /dev/null @@ -1,730 +0,0 @@ -"""Integration tests for the synapseclient.models.Project class.""" - -import os -import uuid -from typing import Callable, List - -import pytest - -from synapseclient import Synapse -from synapseclient.core import utils -from synapseclient.core.exceptions import SynapseHTTPError -from synapseclient.models import ( - Column, - ColumnType, - Dataset, - DatasetCollection, - EntityRef, - EntityView, - File, - Folder, - MaterializedView, - Project, - Table, - ViewTypeMask, - VirtualTable, -) - -CONTENT_TYPE = "text/plain" -DESCRIPTION_FILE = "This is an example file." -DESCRIPTION_PROJECT = "This is an example project." - - -class TestProjectStore: - """Tests for the synapseclient.models.Project.store method.""" - - @pytest.fixture(autouse=True, scope="function") - def init(self, syn: Synapse, schedule_for_cleanup: Callable[..., None]) -> None: - self.syn = syn - self.schedule_for_cleanup = schedule_for_cleanup - - def create_file_instance(self, schedule_for_cleanup: Callable[..., None]) -> File: - filename = utils.make_bogus_uuid_file() - schedule_for_cleanup(filename) - return File( - path=filename, - description=DESCRIPTION_FILE, - content_type=CONTENT_TYPE, - ) - - def create_files(self, count: int) -> List[File]: - """Helper method to create multiple file instances""" - return [ - self.create_file_instance(self.schedule_for_cleanup) for _ in range(count) - ] - - @pytest.fixture(autouse=True, scope="function") - def file(self, schedule_for_cleanup: Callable[..., None]) -> File: - return self.create_file_instance(schedule_for_cleanup) - - @pytest.fixture(autouse=True, scope="function") - def project(self) -> Project: - project = Project(name=str(uuid.uuid4()), description=DESCRIPTION_PROJECT) - return project - - def verify_project_properties( - self, - project: Project, - expected_files: list = None, - expected_folders: list = None, - ): - """Helper method to verify project properties""" - assert project.id is not None - assert project.name is not None - assert project.parent_id is not None - assert project.description is not None - assert project.etag is not None - assert project.created_on is not None - assert project.modified_on is not None - assert project.created_by is not None - assert project.modified_by is not None - - if expected_files is None: - assert project.files == [] - else: - assert project.files == expected_files - # Verify files properties - for file in project.files: - assert file.id is not None - assert file.name is not None - assert file.parent_id == project.id - assert file.path is not None - - if expected_folders is None: - assert project.folders == [] - else: - assert project.folders == expected_folders - # Verify folders properties - for folder in project.folders: - assert folder.id is not None - assert folder.name is not None - assert folder.parent_id == project.id - - # Verify files in folders - for sub_file in folder.files: - assert sub_file.id is not None - assert sub_file.name is not None - assert sub_file.parent_id == folder.id - assert sub_file.path is not None - - # Only check for empty annotations if this is a basic project test without files or folders - # and there are no expected annotation values from the test case - if ( - not expected_files - and not expected_folders - and "my_key_" not in str(project.annotations) - ): - assert not project.annotations and isinstance(project.annotations, dict) - - def test_store_project_basic(self, project: Project) -> None: - # Test Case 1: Basic project storage - # GIVEN a Project object - - # WHEN I store the Project on Synapse - stored_project = project.store(synapse_client=self.syn) - self.schedule_for_cleanup(project.id) - - # THEN I expect the stored Project to have the expected properties - self.verify_project_properties(stored_project) - - # Test Case 2: Project with annotations - # GIVEN a Project object with annotations - project_with_annotations = Project( - name=str(uuid.uuid4()), description=DESCRIPTION_PROJECT - ) - annotations = { - "my_single_key_string": ["a"], - "my_key_string": ["b", "a", "c"], - "my_key_bool": [False, False, False], - "my_key_double": [1.2, 3.4, 5.6], - "my_key_long": [1, 2, 3], - } - project_with_annotations.annotations = annotations - - # WHEN I store the Project on Synapse - stored_project_with_annotations = project_with_annotations.store( - synapse_client=self.syn - ) - self.schedule_for_cleanup(project_with_annotations.id) - - # THEN I expect the stored Project to have the expected properties and annotations - self.verify_project_properties(stored_project_with_annotations) - assert stored_project_with_annotations.annotations == annotations - assert ( - Project(id=stored_project_with_annotations.id).get(synapse_client=self.syn) - ).annotations == annotations - - def test_store_project_with_files(self, file: File, project: Project) -> None: - # Test Case 1: Project with a single file - # GIVEN a File on the project - project.files.append(file) - - # WHEN I store the Project on Synapse - stored_project = project.store(synapse_client=self.syn) - self.schedule_for_cleanup(project.id) - - # THEN I expect the stored Project to have the expected properties and files - self.verify_project_properties(stored_project, expected_files=[file]) - - # Test Case 2: Project with multiple files - # GIVEN multiple files in a project - project_multiple_files = Project( - name=str(uuid.uuid4()), description=DESCRIPTION_PROJECT - ) - files = self.create_files(3) - project_multiple_files.files = files - - # WHEN I store the Project on Synapse - stored_project_multiple_files = project_multiple_files.store( - synapse_client=self.syn - ) - self.schedule_for_cleanup(project_multiple_files.id) - - # THEN I expect the stored Project to have the expected properties and files - self.verify_project_properties( - stored_project_multiple_files, expected_files=files - ) - - def test_store_project_with_nested_structure( - self, file: File, project: Project - ) -> None: - # GIVEN a project with files and folders - - # Create files for the project - project_files = self.create_files(3) - project.files = project_files - - # Create folders with files - folders = [] - for _ in range(2): - sub_folder = Folder(name=str(uuid.uuid4())) - sub_folder.files = self.create_files(2) - folders.append(sub_folder) - project.folders = folders - - # WHEN I store the Project on Synapse - stored_project = project.store(synapse_client=self.syn) - self.schedule_for_cleanup(project.id) - - # THEN I expect the stored Project to have the expected properties, files, and folders - self.verify_project_properties( - stored_project, expected_files=project_files, expected_folders=folders - ) - - # Test Case 2: Store with existing project and nested structure - # GIVEN that a project is already stored in Synapse - existing_project = Project( - name=str(uuid.uuid4()), description=DESCRIPTION_PROJECT - ) - existing_project = existing_project.store(synapse_client=self.syn) - self.schedule_for_cleanup(existing_project.id) - - # AND a Folder with a File under the project - folder = Folder(name=str(uuid.uuid4())) - folder.files.append(file) - existing_project.folders.append(folder) - - # WHEN I store the Project on Synapse - stored_existing_project = existing_project.store(synapse_client=self.syn) - - # THEN I expect the stored Project to have the expected properties - self.verify_project_properties( - stored_existing_project, expected_folders=[folder] - ) - - # AND I expect the Folder to be stored in Synapse - assert folder.id is not None - assert folder.name is not None - assert folder.parent_id == stored_existing_project.id - - # AND I expect the File to be stored on Synapse - assert file.id is not None - assert file.name is not None - assert file.parent_id == folder.id - assert file.path is not None - - -class TestProjectGetDelete: - """Tests for the synapseclient.models.Project.get and delete methods.""" - - @pytest.fixture(autouse=True, scope="function") - def init(self, syn: Synapse, schedule_for_cleanup: Callable[..., None]) -> None: - self.syn = syn - self.schedule_for_cleanup = schedule_for_cleanup - - @pytest.fixture(autouse=True, scope="function") - def project(self) -> Project: - project = Project(name=str(uuid.uuid4()), description=DESCRIPTION_PROJECT) - return project - - def verify_project_properties(self, project: Project): - """Helper method to verify project properties""" - assert project.id is not None - assert project.name is not None - assert project.parent_id is not None - assert project.description is not None - assert project.etag is not None - assert project.created_on is not None - assert project.modified_on is not None - assert project.created_by is not None - assert project.modified_by is not None - assert project.files == [] - assert project.folders == [] - assert not project.annotations and isinstance(project.annotations, dict) - - def test_get_project_methods(self, project: Project) -> None: - # GIVEN a Project object stored in Synapse - stored_project = project.store(synapse_client=self.syn) - self.schedule_for_cleanup(project.id) - - # Test Case 1: Get project by ID - # WHEN I get the Project from Synapse by ID - project_by_id = Project(id=stored_project.id).get(synapse_client=self.syn) - - # THEN I expect the retrieved Project to have the expected properties - self.verify_project_properties(project_by_id) - - # Test Case 2: Get project by name attribute - # WHEN I get the Project from Synapse by name - project_by_name = Project(name=stored_project.name).get(synapse_client=self.syn) - - # THEN I expect the retrieved Project to have the expected properties - self.verify_project_properties(project_by_name) - - def test_delete_project(self, project: Project) -> None: - # GIVEN a Project object stored in Synapse - stored_project = project.store(synapse_client=self.syn) - self.schedule_for_cleanup(project.id) - - # WHEN I delete the Project from Synapse - stored_project.delete(synapse_client=self.syn) - - # THEN I expect the project to have been deleted - with pytest.raises(SynapseHTTPError) as e: - stored_project.get(synapse_client=self.syn) - - assert f"404 Client Error: Entity {stored_project.id} is in trash can." in str( - e.value - ) - - -class TestProjectCopySync: - """Tests for the synapseclient.models.Project.copy and sync_from_synapse methods.""" - - @pytest.fixture(autouse=True, scope="function") - def init(self, syn: Synapse, schedule_for_cleanup: Callable[..., None]) -> None: - self.syn = syn - self.schedule_for_cleanup = schedule_for_cleanup - - def create_file_instance(self, schedule_for_cleanup: Callable[..., None]) -> File: - filename = utils.make_bogus_uuid_file() - schedule_for_cleanup(filename) - return File( - path=filename, - description=DESCRIPTION_FILE, - content_type=CONTENT_TYPE, - ) - - def create_files(self, count: int) -> List[File]: - """Helper method to create multiple file instances""" - return [ - self.create_file_instance(self.schedule_for_cleanup) for _ in range(count) - ] - - @pytest.fixture(autouse=True, scope="function") - def file(self, schedule_for_cleanup: Callable[..., None]) -> File: - return self.create_file_instance(schedule_for_cleanup) - - @pytest.fixture(autouse=True, scope="function") - def project(self) -> Project: - project = Project(name=str(uuid.uuid4()), description=DESCRIPTION_PROJECT) - return project - - def create_nested_project(self) -> Project: - """Helper method to create a project with files and folders""" - project = Project(name=str(uuid.uuid4()), description=DESCRIPTION_PROJECT) - - # Add files to project - project.files = self.create_files(3) - - # Add folders with files - folders = [] - for _ in range(2): - sub_folder = Folder(name=str(uuid.uuid4())) - sub_folder.files = self.create_files(2) - folders.append(sub_folder) - project.folders = folders - - # Add annotations - project.annotations = {"test": ["test"]} - - return project - - def verify_copied_project( - self, - copied_project: Project, - original_project: Project, - expected_files_empty: bool = False, - ): - """Helper method to verify copied project properties""" - assert copied_project.id is not None - assert copied_project.id is not original_project.id - assert copied_project.name is not None - assert copied_project.parent_id is not None - assert copied_project.description is not None - assert copied_project.etag is not None - assert copied_project.created_on is not None - assert copied_project.modified_on is not None - assert copied_project.created_by is not None - assert copied_project.modified_by is not None - assert copied_project.annotations == original_project.annotations - - if expected_files_empty: - assert copied_project.files == [] - else: - assert len(copied_project.files) == len(original_project.files) - for file in copied_project.files: - assert file.id is not None - assert file.name is not None - assert file.parent_id == copied_project.id - - if len(copied_project.folders) > 0: - for folder in copied_project.folders: - assert folder.id is not None - assert folder.name is not None - assert folder.parent_id == copied_project.id - - for sub_file in folder.files: - assert sub_file.id is not None - assert sub_file.name is not None - assert sub_file.parent_id == folder.id - - def test_copy_project_variations(self) -> None: - # GIVEN a nested source project and a destination project - source_project = self.create_nested_project() - stored_source_project = source_project.store(synapse_client=self.syn) - self.schedule_for_cleanup(stored_source_project.id) - - # Test Case 1: Copy project with all contents - # Create first destination project - destination_project_1 = Project( - name=str(uuid.uuid4()), description="Destination for project copy 1" - ).store(synapse_client=self.syn) - self.schedule_for_cleanup(destination_project_1.id) - - # WHEN I copy the project to the destination project - copied_project = stored_source_project.copy( - destination_id=destination_project_1.id, synapse_client=self.syn - ) - - # AND I sync the destination project from Synapse - destination_project_1.sync_from_synapse( - recursive=False, download_file=False, synapse_client=self.syn - ) - - # THEN I expect the copied Project to have the expected properties - assert len(destination_project_1.files) == 3 - assert len(destination_project_1.folders) == 2 - self.verify_copied_project(copied_project, stored_source_project) - - # Test Case 2: Copy project excluding files - # Create a second destination project for the second test case - destination_project_2 = Project( - name=str(uuid.uuid4()), description="Destination for project copy 2" - ).store(synapse_client=self.syn) - self.schedule_for_cleanup(destination_project_2.id) - - # WHEN I copy the project to the destination project excluding files - copied_project_no_files = stored_source_project.copy( - destination_id=destination_project_2.id, - exclude_types=["file"], - synapse_client=self.syn, - ) - - # THEN I expect the copied Project to have the expected properties but no files - self.verify_copied_project( - copied_project_no_files, stored_source_project, expected_files_empty=True - ) - - def test_sync_from_synapse(self, file: File) -> None: - # GIVEN a nested project structure - root_directory_path = os.path.dirname(file.path) - - project = self.create_nested_project() - - # WHEN I store the Project on Synapse - stored_project = project.store(synapse_client=self.syn) - self.schedule_for_cleanup(project.id) - - # AND I sync the project from Synapse - copied_project = stored_project.sync_from_synapse( - path=root_directory_path, synapse_client=self.syn - ) - - # THEN I expect that the project and its contents are synced from Synapse to disk - # Verify files in root folder - for file in copied_project.files: - assert os.path.exists(file.path) - assert os.path.isfile(file.path) - assert ( - utils.md5_for_file(file.path).hexdigest() - == file.file_handle.content_md5 - ) - - # Verify folders and their files - for folder in stored_project.folders: - resolved_path = os.path.join(root_directory_path, folder.name) - assert os.path.exists(resolved_path) - assert os.path.isdir(resolved_path) - - for sub_file in folder.files: - assert os.path.exists(sub_file.path) - assert os.path.isfile(sub_file.path) - assert ( - utils.md5_for_file(sub_file.path).hexdigest() - == sub_file.file_handle.content_md5 - ) - - def test_sync_all_entity_types(self) -> None: - """Test syncing a project with all supported entity types.""" - # GIVEN a project with one of each entity type - - # Create a unique project for this test - project_model = Project( - name=f"test_sync_project_{str(uuid.uuid4())}", - description="Test project for sync all entity types", - ) - project_model = project_model.store(synapse_client=self.syn) - self.schedule_for_cleanup(project_model.id) - - # Create and store a Table - table = Table( - name=f"test_table_{str(uuid.uuid4())}", - parent_id=project_model.id, - columns=[ - Column(name="test_column", column_type=ColumnType.STRING), - Column(name="test_column2", column_type=ColumnType.INTEGER), - ], - ) - table = table.store(synapse_client=self.syn) - self.schedule_for_cleanup(table.id) - - # Create and store an EntityView - entity_view = EntityView( - name=f"test_entityview_{str(uuid.uuid4())}", - parent_id=project_model.id, - scope_ids=[project_model.id], - view_type_mask=ViewTypeMask.FILE, - include_default_columns=True, - ) - entity_view = entity_view.store(synapse_client=self.syn) - self.schedule_for_cleanup(entity_view.id) - - # Create and store a MaterializedView - materialized_view = MaterializedView( - name=f"test_materializedview_{str(uuid.uuid4())}", - parent_id=project_model.id, - defining_sql=f"SELECT * FROM {table.id}", - ) - materialized_view = materialized_view.store(synapse_client=self.syn) - self.schedule_for_cleanup(materialized_view.id) - - # Create and store a VirtualTable - virtual_table = VirtualTable( - name=f"test_virtualtable_{str(uuid.uuid4())}", - parent_id=project_model.id, - defining_sql=f"SELECT * FROM {table.id}", - ) - virtual_table = virtual_table.store(synapse_client=self.syn) - self.schedule_for_cleanup(virtual_table.id) - - # Create and store a File for the dataset - file = File( - name=f"test_file_{str(uuid.uuid4())}.txt", - parent_id=project_model.id, - path=utils.make_bogus_uuid_file(), - ) - file = file.store(synapse_client=self.syn) - self.schedule_for_cleanup(file.id) - - # Create and store a Dataset - dataset = Dataset( - name=f"test_dataset_{str(uuid.uuid4())}", - parent_id=project_model.id, - items=[EntityRef(id=file.id, version=1)], - ) - dataset = dataset.store(synapse_client=self.syn) - self.schedule_for_cleanup(dataset.id) - - # Create and store a DatasetCollection - dataset_collection = DatasetCollection( - name=f"test_datasetcollection_{str(uuid.uuid4())}", - parent_id=project_model.id, - items=[EntityRef(id=dataset.id, version=1)], - ) - dataset_collection = dataset_collection.store(synapse_client=self.syn) - self.schedule_for_cleanup(dataset_collection.id) - - # WHEN I sync the project from Synapse - synced_project = project_model.sync_from_synapse( - recursive=False, download_file=False, synapse_client=self.syn - ) - - # THEN all entity types should be present - assert len(synced_project.tables) == 1 - assert synced_project.tables[0].id == table.id - assert synced_project.tables[0].name == table.name - - assert len(synced_project.entityviews) == 1 - assert synced_project.entityviews[0].id == entity_view.id - assert synced_project.entityviews[0].name == entity_view.name - - assert len(synced_project.materializedviews) == 1 - assert synced_project.materializedviews[0].id == materialized_view.id - assert synced_project.materializedviews[0].name == materialized_view.name - - assert len(synced_project.virtualtables) == 1 - assert synced_project.virtualtables[0].id == virtual_table.id - assert synced_project.virtualtables[0].name == virtual_table.name - - assert len(synced_project.datasets) == 1 - assert synced_project.datasets[0].id == dataset.id - assert synced_project.datasets[0].name == dataset.name - - assert len(synced_project.datasetcollections) == 1 - assert synced_project.datasetcollections[0].id == dataset_collection.id - assert synced_project.datasetcollections[0].name == dataset_collection.name - - # Verify that submission views are empty (since we didn't create any evaluation queues) - assert len(synced_project.submissionviews) == 0 - - -class TestProjectWalk: - """Tests for the synapseclient.models.Project.walk methods.""" - - @pytest.fixture(autouse=True, scope="function") - def init( - self, syn_with_logger: Synapse, schedule_for_cleanup: Callable[..., None] - ) -> None: - self.syn = syn_with_logger - self.schedule_for_cleanup = schedule_for_cleanup - - def create_file_instance(self, schedule_for_cleanup: Callable[..., None]) -> File: - filename = utils.make_bogus_uuid_file() - schedule_for_cleanup(filename) - return File( - path=filename, - description=DESCRIPTION_FILE, - content_type=CONTENT_TYPE, - ) - - def create_test_hierarchy(self, project: Project) -> dict: - """Create a test hierarchy for walk testing.""" - # Create root level folder and file - root_folder = Folder( - name=f"root_folder_{str(uuid.uuid4())[:8]}", parent_id=project.id - ) - root_folder = root_folder.store(synapse_client=self.syn) - self.schedule_for_cleanup(root_folder.id) - - root_file = self.create_file_instance(self.schedule_for_cleanup) - root_file.parent_id = project.id - root_file = root_file.store(synapse_client=self.syn) - self.schedule_for_cleanup(root_file.id) - - # Create nested folder and file - nested_folder = Folder( - name=f"nested_folder_{str(uuid.uuid4())[:8]}", parent_id=root_folder.id - ) - nested_folder = nested_folder.store(synapse_client=self.syn) - self.schedule_for_cleanup(nested_folder.id) - - nested_file = self.create_file_instance(self.schedule_for_cleanup) - nested_file.parent_id = nested_folder.id - nested_file = nested_file.store(synapse_client=self.syn) - self.schedule_for_cleanup(nested_file.id) - - return { - "project": project, - "root_folder": root_folder, - "root_file": root_file, - "nested_folder": nested_folder, - "nested_file": nested_file, - } - - def test_walk_recursive_true(self) -> None: - """Test walk method with recursive=True.""" - # GIVEN: A unique project with a hierarchical structure - project_model = Project( - name=f"integration_test_project{str(uuid.uuid4())}", - description=DESCRIPTION_PROJECT, - ) - project_model = project_model.store(synapse_client=self.syn) - self.schedule_for_cleanup(project_model.id) - hierarchy = self.create_test_hierarchy(project_model) - - # WHEN: Walking through the project with recursive=True - results = list(project_model.walk(recursive=True, synapse_client=self.syn)) - - # THEN: Should get 3 results (project root, root_folder, nested_folder) - assert len(results) == 3 - - # AND: Project root result should contain correct structure - project_result = results[0] - dirpath, dirs, nondirs = project_result - assert dirpath[0] == hierarchy["project"].name - assert dirpath[1] == hierarchy["project"].id - assert len(dirs) == 1 # root_folder - assert len(nondirs) == 1 # root_file - - # AND: All returned objects should be EntityHeader instances - assert hasattr(dirs[0], "name") - assert hasattr(dirs[0], "id") - assert hasattr(dirs[0], "type") - assert hasattr(nondirs[0], "name") - assert hasattr(nondirs[0], "id") - assert hasattr(nondirs[0], "type") - - # AND: Should be able to find nested content - nested_results = [r for r in results if "nested_folder" in r[0][0]] - assert len(nested_results) == 1 - _, nested_dirs, nested_nondirs = nested_results[0] - assert len(nested_dirs) == 0 - assert len(nested_nondirs) == 1 # nested_file - - # AND: Nested objects should also be EntityHeader instances - assert hasattr(nested_nondirs[0], "name") - assert hasattr(nested_nondirs[0], "id") - assert hasattr(nested_nondirs[0], "type") - - def test_walk_recursive_false(self) -> None: - """Test walk method with recursive=False.""" - # GIVEN: A unique project with a hierarchical structure - project_model = Project( - name=f"integration_test_project{str(uuid.uuid4())}", - description=DESCRIPTION_PROJECT, - ) - project_model = project_model.store(synapse_client=self.syn) - self.schedule_for_cleanup(project_model.id) - hierarchy = self.create_test_hierarchy(project_model) - - # WHEN: Walking through the project with recursive=False - results = list(project_model.walk(recursive=False, synapse_client=self.syn)) - - # THEN: Should get only 1 result (project root only) - assert len(results) == 1 - - # AND: Project root should contain direct children only - dirpath, dirs, nondirs = results[0] - assert dirpath[0] == hierarchy["project"].name - assert dirpath[1] == hierarchy["project"].id - assert len(dirs) == 1 # root_folder - assert len(nondirs) == 1 # root_file - - # AND: All returned objects should be EntityHeader instances - assert hasattr(dirs[0], "name") - assert hasattr(dirs[0], "id") - assert hasattr(dirs[0], "type") - assert hasattr(nondirs[0], "name") - assert hasattr(nondirs[0], "id") - assert hasattr(nondirs[0], "type") diff --git a/tests/integration/synapseclient/models/synchronous/test_recordset.py b/tests/integration/synapseclient/models/synchronous/test_recordset.py deleted file mode 100644 index 25bc9e2a3..000000000 --- a/tests/integration/synapseclient/models/synchronous/test_recordset.py +++ /dev/null @@ -1,737 +0,0 @@ -"""Integration tests for the synapseclient.models.RecordSet class.""" - -import os -import tempfile -import time -import uuid -from typing import Callable, Generator, Tuple - -import pandas as pd -import pytest - -from synapseclient import Synapse -from synapseclient.core import utils -from synapseclient.core.exceptions import SynapseHTTPError -from synapseclient.models import ( - Activity, - Folder, - Project, - RecordSet, - UsedEntity, - UsedURL, -) -from synapseclient.models.curation import Grid -from synapseclient.services.json_schema import JsonSchemaOrganization - - -class TestRecordSetStore: - """Tests for the RecordSet.store method.""" - - @pytest.fixture(autouse=True, scope="function") - def init(self, syn: Synapse, schedule_for_cleanup: Callable[..., None]) -> None: - self.syn = syn - self.schedule_for_cleanup = schedule_for_cleanup - - @pytest.fixture(autouse=True, scope="function") - def record_set_fixture( - self, schedule_for_cleanup: Callable[..., None] - ) -> RecordSet: - """Create a RecordSet fixture for testing.""" - filename = utils.make_bogus_uuid_file() - schedule_for_cleanup(filename) - return RecordSet( - path=filename, - description="This is a test RecordSet.", - version_comment="My version comment", - version_label=str(uuid.uuid4()), - upsert_keys=["id", "name"], - ) - - def test_store_in_project( - self, project_model: Project, record_set_fixture: RecordSet - ) -> None: - # GIVEN a RecordSet - record_set_fixture.name = str(uuid.uuid4()) - - # WHEN I store the RecordSet in a project - stored_record_set = record_set_fixture.store( - parent=project_model, synapse_client=self.syn - ) - self.schedule_for_cleanup(stored_record_set.id) - - # THEN the RecordSet should be stored successfully - assert stored_record_set.id is not None - assert stored_record_set.name == record_set_fixture.name - assert stored_record_set.description == "This is a test RecordSet." - assert stored_record_set.version_comment == "My version comment" - assert stored_record_set.upsert_keys == ["id", "name"] - assert stored_record_set.parent_id == project_model.id - assert stored_record_set.etag is not None - assert stored_record_set.created_on is not None - assert stored_record_set.created_by is not None - - def test_store_in_folder( - self, project_model: Project, record_set_fixture: RecordSet - ) -> None: - # GIVEN a folder within a project - folder = Folder( - name=str(uuid.uuid4()), - parent_id=project_model.id, - ).store(synapse_client=self.syn) - self.schedule_for_cleanup(folder.id) - - # AND a RecordSet - record_set_fixture.name = str(uuid.uuid4()) - - # WHEN I store the RecordSet in the folder - stored_record_set = record_set_fixture.store( - parent=folder, synapse_client=self.syn - ) - self.schedule_for_cleanup(stored_record_set.id) - - # THEN the RecordSet should be stored successfully in the folder - assert stored_record_set.id is not None - assert stored_record_set.name == record_set_fixture.name - assert stored_record_set.parent_id == folder.id - assert stored_record_set.etag is not None - - def test_store_with_activity( - self, project_model: Project, record_set_fixture: RecordSet - ) -> None: - # GIVEN a RecordSet with activity - record_set_fixture.name = str(uuid.uuid4()) - activity = Activity( - name="Test Activity", - description="Test activity for RecordSet", - used=[ - UsedURL(name="Example URL", url="https://example.com"), - UsedEntity(target_id="syn123456"), - ], - ) - record_set_fixture.activity = activity - - # WHEN I store the RecordSet - stored_record_set = record_set_fixture.store( - parent=project_model, synapse_client=self.syn - ) - self.schedule_for_cleanup(stored_record_set.id) - - # THEN the RecordSet and activity should be stored successfully - assert stored_record_set.id is not None - assert stored_record_set.activity is not None - assert stored_record_set.activity.name == "Test Activity" - assert stored_record_set.activity.description == "Test activity for RecordSet" - assert len(stored_record_set.activity.used) == 2 - - def test_store_with_annotations( - self, project_model: Project, record_set_fixture: RecordSet - ) -> None: - # GIVEN a RecordSet with annotations - record_set_fixture.name = str(uuid.uuid4()) - record_set_fixture.annotations = { - "test_annotation": ["test_value"], - "numeric_annotation": [42], - "boolean_annotation": [True], - } - - # WHEN I store the RecordSet - stored_record_set = record_set_fixture.store( - parent=project_model, synapse_client=self.syn - ) - self.schedule_for_cleanup(stored_record_set.id) - - # THEN the RecordSet should be stored with annotations - assert stored_record_set.id is not None - assert stored_record_set.annotations is not None - assert "test_annotation" in stored_record_set.annotations - assert "numeric_annotation" in stored_record_set.annotations - assert "boolean_annotation" in stored_record_set.annotations - assert stored_record_set.annotations["test_annotation"] == ["test_value"] - assert stored_record_set.annotations["numeric_annotation"] == [42] - assert stored_record_set.annotations["boolean_annotation"] == [True] - - def test_store_update_existing_record_set( - self, project_model: Project, record_set_fixture: RecordSet - ) -> None: - # GIVEN an existing RecordSet - record_set_fixture.name = str(uuid.uuid4()) - original_record_set = record_set_fixture.store( - parent=project_model, synapse_client=self.syn - ) - self.schedule_for_cleanup(original_record_set.id) - - # WHEN I update the RecordSet with new metadata - original_record_set.description = "Updated description" - original_record_set.version_comment = "Updated version comment" - - updated_record_set = original_record_set.store(synapse_client=self.syn) - - # THEN the RecordSet should be updated successfully - assert updated_record_set.id == original_record_set.id - assert updated_record_set.description == "Updated description" - assert updated_record_set.version_comment == "Updated version comment" - assert updated_record_set.version_number >= original_record_set.version_number - - def test_store_validation_errors(self) -> None: - # GIVEN a RecordSet without required fields - record_set = RecordSet() - - # WHEN I try to store it without proper configuration - # THEN it should raise a ValueError for missing required fields - with pytest.raises(ValueError): - record_set.store(synapse_client=self.syn) - - -class TestRecordSetGet: - """Tests for the RecordSet.get method.""" - - @pytest.fixture(autouse=True, scope="function") - def init(self, syn: Synapse, schedule_for_cleanup: Callable[..., None]) -> None: - self.syn = syn - self.schedule_for_cleanup = schedule_for_cleanup - - @pytest.fixture(scope="function") - def stored_record_set(self, project_model: Project) -> RecordSet: - """Create and store a RecordSet for testing get operations.""" - filename = utils.make_bogus_uuid_file() - self.schedule_for_cleanup(filename) - - record_set = RecordSet( - name=str(uuid.uuid4()), - path=filename, - description="Test RecordSet for get operations", - parent_id=project_model.id, - upsert_keys=["id", "name"], - version_comment="Initial version", - ).store(synapse_client=self.syn) - self.schedule_for_cleanup(record_set.id) - return record_set - - def test_get_record_set_by_id(self, stored_record_set: RecordSet) -> None: - # GIVEN an existing RecordSet - original_id = stored_record_set.id - - # WHEN I get the RecordSet by ID - retrieved_record_set = RecordSet(id=original_id).get(synapse_client=self.syn) - - # THEN the retrieved RecordSet should match the original - assert retrieved_record_set.id == original_id - assert retrieved_record_set.name == stored_record_set.name - assert retrieved_record_set.description == stored_record_set.description - assert retrieved_record_set.parent_id == stored_record_set.parent_id - assert retrieved_record_set.upsert_keys == stored_record_set.upsert_keys - assert retrieved_record_set.version_comment == stored_record_set.version_comment - assert retrieved_record_set.etag == stored_record_set.etag - assert retrieved_record_set.version_number == stored_record_set.version_number - - def test_get_record_set_with_activity(self, project_model: Project) -> None: - # GIVEN a RecordSet with activity - filename = utils.make_bogus_uuid_file() - self.schedule_for_cleanup(filename) - - activity = Activity( - name="Test Activity", - description="Test activity for RecordSet", - used=[UsedURL(name="Test URL", url="https://example.com")], - ) - - record_set = RecordSet( - name=str(uuid.uuid4()), - path=filename, - parent_id=project_model.id, - upsert_keys=["id"], - activity=activity, - ).store(synapse_client=self.syn) - self.schedule_for_cleanup(record_set.id) - - # WHEN I get the RecordSet with activity - retrieved_record_set = RecordSet(id=record_set.id).get( - include_activity=True, synapse_client=self.syn - ) - - # THEN the RecordSet should include the activity - assert retrieved_record_set.activity is not None - assert retrieved_record_set.activity.name == "Test Activity" - assert ( - retrieved_record_set.activity.description == "Test activity for RecordSet" - ) - assert len(retrieved_record_set.activity.used) == 1 - assert retrieved_record_set.path is not None - - def test_get_validation_error(self) -> None: - # GIVEN a RecordSet without an ID - record_set = RecordSet() - - # WHEN I try to get it - # THEN it should raise a ValueError - with pytest.raises(ValueError): - record_set.get(synapse_client=self.syn) - - def test_get_non_existent_record_set(self) -> None: - # GIVEN a non-existent RecordSet ID - record_set = RecordSet(id="syn999999999") - - # WHEN I try to get it - # THEN it should raise a SynapseHTTPError - with pytest.raises(SynapseHTTPError): - record_set.get(synapse_client=self.syn) - - -class TestRecordSetDelete: - """Tests for the RecordSet.delete method.""" - - @pytest.fixture(autouse=True, scope="function") - def init(self, syn: Synapse, schedule_for_cleanup: Callable[..., None]) -> None: - self.syn = syn - self.schedule_for_cleanup = schedule_for_cleanup - - def test_delete_entire_record_set(self, project_model: Project) -> None: - # GIVEN an existing RecordSet - filename = utils.make_bogus_uuid_file() - self.schedule_for_cleanup(filename) - - record_set = RecordSet( - name=str(uuid.uuid4()), - path=filename, - description="RecordSet to be deleted", - parent_id=project_model.id, - upsert_keys=["id"], - ).store(synapse_client=self.syn) - - record_set_id = record_set.id - - # WHEN I delete the entire RecordSet - record_set.delete(synapse_client=self.syn) - - # THEN the RecordSet should be deleted and no longer accessible - with pytest.raises(SynapseHTTPError): - RecordSet(id=record_set_id).get(synapse_client=self.syn) - - def test_delete_specific_version(self, project_model: Project) -> None: - # GIVEN an existing RecordSet with multiple versions - filename1 = utils.make_bogus_uuid_file() - filename2 = utils.make_bogus_uuid_file() - self.schedule_for_cleanup(filename1) - self.schedule_for_cleanup(filename2) - - # Create initial version - record_set = RecordSet( - name=str(uuid.uuid4()), - path=filename1, - description="RecordSet version 1", - parent_id=project_model.id, - upsert_keys=["id"], - ).store(synapse_client=self.syn) - self.schedule_for_cleanup(record_set.id) - - # Create second version - record_set.path = filename2 - record_set.description = "RecordSet version 2" - v2_record_set = record_set.store(synapse_client=self.syn) - - # WHEN I delete only version 2 - v2_record_set.delete(version_only=True, synapse_client=self.syn) - - # THEN the RecordSet should still exist but version 2 should be gone - current_record_set = RecordSet(id=record_set.id).get(synapse_client=self.syn) - assert current_record_set.id == record_set.id - assert current_record_set.version_number == 1 # Should be back to version 1 - assert current_record_set.description == "RecordSet version 1" - - def test_delete_validation_errors(self) -> None: - # GIVEN a RecordSet without an ID - record_set = RecordSet() - - # WHEN I try to delete it - # THEN it should raise a ValueError - with pytest.raises(ValueError): - record_set.delete(synapse_client=self.syn) - - # AND WHEN I try to delete a specific version without version number - record_set_with_id = RecordSet(id="syn123456") - with pytest.raises(ValueError): - record_set_with_id.delete(version_only=True, synapse_client=self.syn) - - -class TestRecordSetGetDetailedValidationResults: - """Tests for the RecordSet.get_detailed_validation_results method.""" - - @pytest.fixture(autouse=True, scope="function") - def init(self, syn: Synapse, schedule_for_cleanup: Callable[..., None]) -> None: - self.syn = syn - self.schedule_for_cleanup = schedule_for_cleanup - - @pytest.fixture(scope="function") - def create_test_schema( - self, syn: Synapse - ) -> Generator[Tuple[JsonSchemaOrganization, str, list], None, None]: - """Create a test JSON schema for RecordSet validation.""" - org_name = "recordsettest" + uuid.uuid4().hex[:6] - schema_name = "recordset.validation.schema" - - js = syn.service("json_schema") - created_org = js.create_organization(org_name) - record_set_ids = [] # Track RecordSets that need schema unbinding - - try: - # Define a schema with comprehensive validation rules to test different error types: - # 1. Required fields (id, name) - # 2. Type constraints (integer, string, number, boolean) - # 3. String constraints (minLength) - # 4. Numeric constraints (minimum, maximum) - # 5. Enum constraints (category must be A, B, C, or D) - schema = { - "$schema": "http://json-schema.org/draft-07/schema#", - "$id": f"https://example.com/schema/{schema_name}.json", - "title": "RecordSet Validation Schema", - "type": "object", - "properties": { - "id": { - "description": "The unique identifier", - "type": "integer", - }, - "name": { - "description": "Name of the record (min 3 characters)", - "type": "string", - "minLength": 3, - }, - "value": { - "description": "Numeric value (must be >= 0 and <= 1000)", - "type": "number", - "minimum": 0, - "maximum": 1000, - }, - "category": { - "description": "Category classification (A, B, C, or D only)", - "type": "string", - "enum": ["A", "B", "C", "D"], - }, - "active": { - "description": "Active status flag", - "type": "boolean", - }, - }, - "required": ["id", "name"], - } - - test_org = js.JsonSchemaOrganization(org_name) - created_schema = test_org.create_json_schema(schema, schema_name, "0.0.1") - yield test_org, created_schema.uri, record_set_ids - finally: - # Unbind schema from any RecordSets before deleting - for record_set_id in record_set_ids: - try: - record_set = RecordSet(id=record_set_id) - record_set.unbind_schema(synapse_client=syn) - except Exception: - pass # Ignore errors if already unbound or deleted - - try: - js.delete_json_schema(created_schema.uri) - except Exception: - pass # Ignore if schema can't be deleted - - try: - js.delete_organization(created_org["id"]) - except Exception: - pass # Ignore if org can't be deleted - - @pytest.fixture(scope="function") - def record_set_with_validation_fixture( - self, - project_model: Project, - create_test_schema: Tuple[JsonSchemaOrganization, str, list], - ) -> RecordSet: - """Create and store a RecordSet with schema bound, then export via Grid to generate validation results.""" - from tests.integration import ASYNC_JOB_TIMEOUT_SEC - - _, schema_uri, record_set_ids = create_test_schema - - # Create test data with multiple types of validation errors: - # Row 1: VALID - all fields correct - # Row 2: VALID - all fields correct - # Row 3: INVALID - missing required 'name' field (None value) - # Row 4: INVALID - multiple violations: - # - name too short ("AB" < minLength of 3) - # - value exceeds maximum (1500 > 1000) - # - category not in enum ("X" not in [A, B, C, D]) - # Row 5: INVALID - value below minimum (-50 < 0) - test_data = pd.DataFrame( - { - "id": [1, 2, 3, 4, 5], - "name": [ - "Alpha", - "Beta", - None, - "AB", - "Epsilon", - ], # Row 3: None, Row 4: too short - "value": [ - 10.5, - 150.3, - 30.7, - 1500.0, - -50.0, - ], # Row 4: too high, Row 5: negative - "category": ["A", "B", "A", "X", "B"], # Row 4: invalid enum value - "active": [True, False, True, True, False], - } - ) - - # Create a temporary CSV file - temp_fd, filename = tempfile.mkstemp(suffix=".csv") - try: - os.close(temp_fd) # Close the file descriptor - test_data.to_csv(filename, index=False) - self.schedule_for_cleanup(filename) - - # Create and store the RecordSet - record_set = RecordSet( - path=filename, - name=str(uuid.uuid4()), - description="Test RecordSet for validation testing", - version_comment="Validation test version", - version_label=str(uuid.uuid4()), - upsert_keys=["id", "name"], - ) - - stored_record_set = record_set.store( - parent=project_model, synapse_client=self.syn - ) - self.schedule_for_cleanup(stored_record_set.id) - record_set_ids.append(stored_record_set.id) # Track for schema cleanup - - time.sleep(10) - - # Bind the JSON schema to the RecordSet - stored_record_set.bind_schema( - json_schema_uri=schema_uri, - enable_derived_annotations=False, - synapse_client=self.syn, - ) - - time.sleep(10) - - # Verify the schema is bound by getting the schema from the entity - stored_record_set.get_schema(synapse_client=self.syn) - - # Create a Grid session from the RecordSet - grid = Grid(record_set_id=stored_record_set.id) - created_grid = grid.create( - timeout=ASYNC_JOB_TIMEOUT_SEC, synapse_client=self.syn - ) - - time.sleep(10) - - # Export the Grid back to RecordSet to generate validation results - exported_grid = created_grid.export_to_record_set( - timeout=ASYNC_JOB_TIMEOUT_SEC, synapse_client=self.syn - ) - - # Clean up the Grid session - exported_grid.delete(synapse_client=self.syn) - - # Re-fetch the RecordSet to get the updated validation_file_handle_id - updated_record_set = RecordSet(id=stored_record_set.id).get( - synapse_client=self.syn - ) - - return updated_record_set - except Exception: - # Clean up the temp file if something goes wrong - if os.path.exists(filename): - os.unlink(filename) - raise - - def test_get_validation_results_no_file_handle_id( - self, project_model: Project - ) -> None: - # GIVEN a RecordSet without a validation_file_handle_id - filename = utils.make_bogus_uuid_file() - self.schedule_for_cleanup(filename) - - record_set = RecordSet( - name=str(uuid.uuid4()), - path=filename, - description="RecordSet without validation", - parent_id=project_model.id, - upsert_keys=["id"], - ).store(synapse_client=self.syn) - self.schedule_for_cleanup(record_set.id) - - # WHEN I try to get detailed validation results - result = record_set.get_detailed_validation_results(synapse_client=self.syn) - - # THEN it should return None and log a warning - assert result is None - assert record_set.validation_file_handle_id is None - - def test_get_validation_results_with_default_location( - self, record_set_with_validation_fixture: RecordSet - ) -> None: - # GIVEN a RecordSet with validation results - record_set = record_set_with_validation_fixture - - # WHEN I get detailed validation results without specifying a location - results_df = record_set.get_detailed_validation_results(synapse_client=self.syn) - - # THEN it should return a pandas DataFrame - assert results_df is not None - assert isinstance(results_df, pd.DataFrame) - # The validation results file should be downloaded to the cache - assert record_set.validation_file_handle_id is not None - - # AND the DataFrame should contain expected columns for validation results - expected_columns = [ - "row_index", - "is_valid", - "validation_error_message", - "all_validation_messages", - ] - for col in expected_columns: - assert ( - col in results_df.columns - ), f"Expected column '{col}' not found in validation results" - - # AND there should be 5 rows (one per data row) - assert ( - len(results_df) == 5 - ), f"Expected 5 rows in validation results, got {len(results_df)}" - - # Debug: Print actual validation results to diagnose the issue - print("\n=== Debug: Validation Results ===") - print(f"DataFrame shape: {results_df.shape}") - print(f"DataFrame dtypes:\n{results_df.dtypes}") - print("\nValidation results:") - print(results_df.to_string()) - print(f"\nis_valid column unique values: {results_df['is_valid'].unique()}") - print(f"is_valid column dtype: {results_df['is_valid'].dtype}") - print("=== End Debug ===") - - # AND rows 0 and 1 should be valid (is_valid == True) - assert ( - results_df.loc[0, "is_valid"] == True - ), "Row 0 should be valid" # noqa: E712 - assert ( - results_df.loc[1, "is_valid"] == True - ), "Row 1 should be valid" # noqa: E712 - assert pd.isna( - results_df.loc[0, "validation_error_message"] - ), "Row 0 should have no error message" - assert pd.isna( - results_df.loc[1, "validation_error_message"] - ), "Row 1 should have no error message" - - # AND row 2 should be invalid (missing required 'name' field) - assert ( - results_df.loc[2, "is_valid"] == False - ), "Row 2 should be invalid (missing required name)" # noqa: E712 - assert ( - "expected type: String, found: Null" - in results_df.loc[2, "validation_error_message"] - ), f"Row 2 should have null type error, got: {results_df.loc[2, 'validation_error_message']}" - assert "#/name: expected type: String, found: Null" in str( - results_df.loc[2, "all_validation_messages"] - ), f"Row 2 all_validation_messages incorrect: {results_df.loc[2, 'all_validation_messages']}" - - # AND row 3 should be invalid (multiple violations: minLength, maximum, enum) - assert ( - results_df.loc[3, "is_valid"] == False - ), "Row 3 should be invalid (multiple violations)" # noqa: E712 - assert ( - "3 schema violations found" in results_df.loc[3, "validation_error_message"] - ), f"Row 3 should have 3 violations, got: {results_df.loc[3, 'validation_error_message']}" - all_msgs_3 = str(results_df.loc[3, "all_validation_messages"]) - assert ( - "#/name: expected minLength: 3, actual: 2" in all_msgs_3 - ), f"Row 3 should have minLength violation: {all_msgs_3}" - assert ( - "#/value: 1500 is not less or equal to 1000" in all_msgs_3 - or "1500" in all_msgs_3 - ), f"Row 3 should have maximum violation: {all_msgs_3}" - assert ( - "#/category: X is not a valid enum value" in all_msgs_3 - or "enum" in all_msgs_3.lower() - ), f"Row 3 should have enum violation: {all_msgs_3}" - - # AND row 4 should be invalid (value below minimum) - assert ( - results_df.loc[4, "is_valid"] == False - ), "Row 4 should be invalid (value below minimum)" # noqa: E712 - assert ( - "-50.0 is not greater or equal to 0" - in results_df.loc[4, "validation_error_message"] - ), f"Row 4 should have minimum violation, got: {results_df.loc[4, 'validation_error_message']}" - assert "#/value: -50.0 is not greater or equal to 0" in str( - results_df.loc[4, "all_validation_messages"] - ), f"Row 4 all_validation_messages incorrect: {results_df.loc[4, 'all_validation_messages']}" - - def test_get_validation_results_with_custom_location( - self, record_set_with_validation_fixture: RecordSet - ) -> None: - # GIVEN a RecordSet with validation results - record_set = record_set_with_validation_fixture - - # AND a custom download location - custom_location = tempfile.mkdtemp() - self.schedule_for_cleanup(custom_location) - - # WHEN I get detailed validation results with a custom location - results_df = record_set.get_detailed_validation_results( - download_location=custom_location, synapse_client=self.syn - ) - - # THEN it should return a pandas DataFrame - assert results_df is not None - assert isinstance(results_df, pd.DataFrame) - - # AND the file should be downloaded to the custom location - expected_filename = ( - f"SYNAPSE_RECORDSET_VALIDATION_{record_set.validation_file_handle_id}.csv" - ) - expected_path = os.path.join(custom_location, expected_filename) - assert os.path.exists(expected_path) - - # AND the DataFrame should contain validation result columns - assert "row_index" in results_df.columns - assert "is_valid" in results_df.columns - assert "validation_error_message" in results_df.columns - assert "all_validation_messages" in results_df.columns - - # Expected behavior: 3 invalid rows (rows 2, 3, 4) and 2 valid rows (rows 0, 1) - invalid_rows = results_df[results_df["is_valid"] == False] # noqa: E712 - assert ( - len(invalid_rows) == 3 - ), f"Expected 3 invalid rows, got {len(invalid_rows)}" - - valid_rows = results_df[results_df["is_valid"] == True] # noqa: E712 - assert len(valid_rows) == 2, f"Expected 2 valid rows, got {len(valid_rows)}" - - # All invalid rows should have validation error messages - for idx, row in invalid_rows.iterrows(): - assert pd.notna( - row["validation_error_message"] - ), f"Row {idx} is marked invalid but has no validation_error_message" - assert pd.notna( - row["all_validation_messages"] - ), f"Row {idx} is marked invalid but has no all_validation_messages" - - def test_get_validation_results_no_file_handle_emits_warning( - self, syn_with_logger: Synapse, caplog: pytest.LogCaptureFixture - ) -> None: - # GIVEN a RecordSet without an ID - record_set = RecordSet() - - # WHEN I try to get detailed validation results - result = record_set.get_detailed_validation_results( - synapse_client=syn_with_logger - ) - - # THEN it should return None since there's no validation_file_handle_id - assert result is None - - # AND a warning should be logged - assert ( - "No validation file handle ID found for this RecordSet. Cannot retrieve detailed validation results." - in caplog.text - ) diff --git a/tests/integration/synapseclient/models/synchronous/test_schema_organization.py b/tests/integration/synapseclient/models/synchronous/test_schema_organization.py deleted file mode 100644 index 4dea1e3e6..000000000 --- a/tests/integration/synapseclient/models/synchronous/test_schema_organization.py +++ /dev/null @@ -1,309 +0,0 @@ -"""Integration tests for SchemaOrganization and JSONSchema classes""" -import time -import uuid -from typing import Any, Optional - -import pytest - -from synapseclient import Synapse -from synapseclient.core.exceptions import SynapseHTTPError -from synapseclient.models import JSONSchema, SchemaOrganization -from synapseclient.models.schema_organization import list_json_schema_organizations - - -def create_test_entity_name(): - """Creates a random string for naming orgs and schemas in Synapse for testing - - Returns: - A legal Synapse org/schema name - """ - random_string = "".join(i for i in str(uuid.uuid4()) if i.isalpha()) - return f"SYNPY.TEST.{random_string}" - - -def org_exists(name: str, synapse_client: Optional[Synapse] = None) -> bool: - """ - Checks if any organizations exists with the given name - - Args: - name: the name to check - syn: Synapse client - - Returns: - bool: True if an organization match the given name - """ - matching_orgs = [ - org - for org in list_json_schema_organizations(synapse_client=synapse_client) - if org.name == name - ] - return len(matching_orgs) == 1 - - -@pytest.fixture(name="module_organization", scope="module") -def fixture_module_organization(syn: Synapse, request) -> SchemaOrganization: - """ - Returns a created organization at the module scope. Used to hold JSON Schemas created by tests. - """ - org = SchemaOrganization(create_test_entity_name()) - org.store(synapse_client=syn) - - def delete_org(): - for schema in org.get_json_schemas(synapse_client=syn): - schema.delete(synapse_client=syn) - org.delete(synapse_client=syn) - - request.addfinalizer(delete_org) - - return org - - -@pytest.fixture(name="json_schema", scope="function") -def fixture_json_schema(module_organization: SchemaOrganization) -> JSONSchema: - """ - Returns a JSON Schema - """ - js = JSONSchema(create_test_entity_name(), module_organization.name) - return js - - -@pytest.fixture(name="organization", scope="function") -def fixture_organization(syn: Synapse, request) -> SchemaOrganization: - """ - Returns a Synapse organization. - """ - name = create_test_entity_name() - org = SchemaOrganization(name) - - def delete_org(): - exists = org_exists(name=name, synapse_client=syn) - if exists: - org.delete(synapse_client=syn) - - request.addfinalizer(delete_org) - - return org - - -@pytest.fixture(name="organization_with_schema", scope="function") -def fixture_organization_with_schema(syn: Synapse, request) -> SchemaOrganization: - """ - Returns a Synapse organization. - As Cleanup it checks for JSON Schemas and deletes them""" - name = create_test_entity_name() - org = SchemaOrganization(name) - org.store(synapse_client=syn) - js1 = JSONSchema("schema1", name) - js2 = JSONSchema("schema2", name) - js3 = JSONSchema("schema3", name) - js1.store(schema_body={}, synapse_client=syn) - js2.store(schema_body={}, synapse_client=syn) - js3.store(schema_body={}, synapse_client=syn) - - def delete_org(): - for schema in org.get_json_schemas(synapse_client=syn): - schema.delete(synapse_client=syn) - org.delete(synapse_client=syn) - - request.addfinalizer(delete_org) - - return org - - -class TestSchemaOrganization: - """Synchronous integration tests for SchemaOrganization.""" - - @pytest.fixture(autouse=True, scope="function") - def init(self, syn: Synapse) -> None: - self.syn = syn - - def test_create_and_get(self, organization: SchemaOrganization) -> None: - # GIVEN an initialized organization object that hasn't been stored in Synapse - # THEN it shouldn't have any metadata besides it's name - assert organization.name is not None - assert organization.id is None - assert organization.created_by is None - assert organization.created_on is None - # AND it shouldn't exist in Synapse - exists = org_exists(organization.name, synapse_client=self.syn) - assert not exists - # WHEN I store the organization the metadata will be saved - organization.store(synapse_client=self.syn) - time.sleep(10) - assert organization.name is not None - assert organization.id is not None - assert organization.created_by is not None - assert organization.created_on is not None - # AND it should exist in Synapse - exists = org_exists(organization.name, synapse_client=self.syn) - assert exists - # AND it should be getable by future instances with the same name - org2 = SchemaOrganization(organization.name) - org2.get(synapse_client=self.syn) - assert organization.name is not None - assert organization.id is not None - assert organization.created_by is not None - assert organization.created_on is not None - # WHEN I try to store an organization that exists in Synapse - # THEN I should get an exception - with pytest.raises(SynapseHTTPError): - org2.store(synapse_client=self.syn) - - def test_get_json_schemas( - self, - organization: SchemaOrganization, - organization_with_schema: SchemaOrganization, - ) -> None: - # GIVEN an organization with no schemas and one with 3 schemas - organization.store(synapse_client=self.syn) - time.sleep(10) - # THEN get_json_schema_list should return the correct list of schemas - assert len(list(organization.get_json_schemas(synapse_client=self.syn))) == 0 - assert ( - len( - list(organization_with_schema.get_json_schemas(synapse_client=self.syn)) - ) - == 3 - ) - - def test_get_acl_and_update_acl(self, organization: SchemaOrganization) -> None: - # GIVEN an organization that has been initialized, but not created - # THEN get_acl should raise an error - with pytest.raises( - SynapseHTTPError, match="404 Client Error: Organization with name" - ): - organization.get_acl(synapse_client=self.syn) - # GIVEN an organization that has been created - organization.store(synapse_client=self.syn) - time.sleep(10) - acl = organization.get_acl(synapse_client=self.syn) - resource_access: list[dict[str, Any]] = acl["resourceAccess"] - # THEN the resource access should be have one principal - assert len(resource_access) == 1 - # WHEN adding another principal to the resource access - # AND updating the acl - organization.update_acl(1, ["READ"], synapse_client=self.syn) - # THEN the resource access should be have two principals - acl = organization.get_acl(synapse_client=self.syn) - assert len(acl["resourceAccess"]) == 2 - - -class TestJSONSchema: - """Synchronous integration tests for JSONSchema.""" - - @pytest.fixture(autouse=True, scope="function") - def init(self, syn: Synapse) -> None: - self.syn = syn - - def test_store_and_get(self, json_schema: JSONSchema) -> None: - # GIVEN an initialized schema object that hasn't been stored in Synapse - # THEN it shouldn't have any metadata besides it's name and organization name, and uri - assert json_schema.name - assert json_schema.organization_name - assert json_schema.uri - assert not json_schema.organization_id - assert not json_schema.id - assert not json_schema.created_by - assert not json_schema.created_on - # WHEN the object is created - json_schema.store({}, synapse_client=self.syn) - assert json_schema.name - assert json_schema.organization_name - assert json_schema.uri - assert json_schema.organization_id - assert json_schema.created_by - assert json_schema.created_on - assert not json_schema.id - # AND it should be getable by future instances with the same name - js2 = JSONSchema(json_schema.name, json_schema.organization_name) - js2.get(synapse_client=self.syn) - assert js2.name - assert js2.organization_name - assert js2.uri - assert js2.organization_id - assert js2.id - assert js2.created_by - assert js2.created_on - - def test_delete(self, organization_with_schema: SchemaOrganization) -> None: - # GIVEN an organization with 3 schema - schemas = list( - organization_with_schema.get_json_schemas(synapse_client=self.syn) - ) - assert len(schemas) == 3 - # WHEN deleting one of those schemas - schema = schemas[0] - schema.delete(synapse_client=self.syn) - # THEN there should be only two left - schemas = list( - organization_with_schema.get_json_schemas(synapse_client=self.syn) - ) - assert len(schemas) == 2 - - def test_delete_version(self, json_schema: JSONSchema) -> None: - # GIVEN an organization and a JSONSchema - json_schema.store(schema_body={}, version="0.0.1", synapse_client=self.syn) - # THEN that schema should have one version - js_versions = list(json_schema.get_versions(synapse_client=self.syn)) - assert len(js_versions) == 1 - # WHEN storing a second version - json_schema.store(schema_body={}, version="0.0.2", synapse_client=self.syn) - # THEN that schema should have two versions - js_versions = list(json_schema.get_versions(synapse_client=self.syn)) - assert len(js_versions) == 2 - # AND they should be the ones stored - versions = [js_version.semantic_version for js_version in js_versions] - assert versions == ["0.0.1", "0.0.2"] - # WHEN deleting the first schema version - json_schema.delete(version="0.0.1", synapse_client=self.syn) - # THEN there should only be one version left - js_versions = list(json_schema.get_versions(synapse_client=self.syn)) - assert len(js_versions) == 1 - # AND it should be the second version - versions = [js_version.semantic_version for js_version in js_versions] - assert versions == ["0.0.2"] - - def test_get_versions(self, json_schema: JSONSchema) -> None: - # GIVEN an schema that hasn't been created - # THEN get_versions should return an empty list - assert len(list(json_schema.get_versions(synapse_client=self.syn))) == 0 - # WHEN creating a schema with no version - json_schema.store(schema_body={}, synapse_client=self.syn) - # THEN get_versions should return an empty list - assert len(list(json_schema.get_versions(synapse_client=self.syn))) == 0 - # WHEN creating a schema with a version - json_schema.store(schema_body={}, version="0.0.1", synapse_client=self.syn) - # THEN get_versions should return that version - schemas = list(json_schema.get_versions(synapse_client=self.syn)) - assert len(schemas) == 1 - assert schemas[0].semantic_version == "0.0.1" - - def test_get_body(self, json_schema: JSONSchema) -> None: - # GIVEN an schema that hasn't been created - # WHEN creating a schema with 2 version - first_body = {} - latest_body = {"description": ""} - json_schema.store( - schema_body=first_body, version="0.0.1", synapse_client=self.syn - ) - json_schema.store( - schema_body=latest_body, version="0.0.2", synapse_client=self.syn - ) - # WHEN get_body has no version argument - body0 = json_schema.get_body(synapse_client=self.syn) - # THEN the body should be the latest version - assert body0 == { - "description": "", - "$id": f"https://repo-prod.prod.sagebase.org/repo/v1/schema/type/registered/{json_schema.organization_name}-{json_schema.name}", - } - # WHEN get_body has a version argument - body1 = json_schema.get_body(version="0.0.1", synapse_client=self.syn) - body2 = json_schema.get_body(version="0.0.2", synapse_client=self.syn) - # THEN the appropriate body should be returned - assert body1 == { - "$id": f"https://repo-prod.prod.sagebase.org/repo/v1/schema/type/registered/{json_schema.organization_name}-{json_schema.name}-0.0.1", - } - assert body2 == { - "description": "", - "$id": f"https://repo-prod.prod.sagebase.org/repo/v1/schema/type/registered/{json_schema.organization_name}-{json_schema.name}-0.0.2", - } diff --git a/tests/integration/synapseclient/models/synchronous/test_submission.py b/tests/integration/synapseclient/models/synchronous/test_submission.py deleted file mode 100644 index a5ead72a7..000000000 --- a/tests/integration/synapseclient/models/synchronous/test_submission.py +++ /dev/null @@ -1,607 +0,0 @@ -"""Integration tests for the synapseclient.models.Submission class.""" - -import uuid -from typing import Callable - -import pytest - -from synapseclient import Synapse -from synapseclient.core.exceptions import SynapseHTTPError -from synapseclient.models import Evaluation, File, Project, Submission - - -class TestSubmissionCreation: - @pytest.fixture(autouse=True, scope="function") - def init(self, syn: Synapse, schedule_for_cleanup: Callable[..., None]) -> None: - self.syn = syn - self.schedule_for_cleanup = schedule_for_cleanup - - @pytest.fixture(scope="function") - def test_project( - self, syn: Synapse, schedule_for_cleanup: Callable[..., None] - ) -> Project: - """Create a test project for submission tests.""" - project = Project(name=f"test_project_{uuid.uuid4()}").store(synapse_client=syn) - schedule_for_cleanup(project.id) - return project - - @pytest.fixture(scope="function") - def test_evaluation( - self, - test_project: Project, - syn: Synapse, - schedule_for_cleanup: Callable[..., None], - ) -> Evaluation: - """Create a test evaluation for submission tests.""" - evaluation = Evaluation( - name=f"test_evaluation_{uuid.uuid4()}", - description="A test evaluation for submission tests", - content_source=test_project.id, - submission_instructions_message="Please submit your results", - submission_receipt_message="Thank you!", - ) - created_evaluation = evaluation.store(synapse_client=syn) - schedule_for_cleanup(created_evaluation.id) - return created_evaluation - - @pytest.fixture(scope="function") - def test_file( - self, - test_project: Project, - syn: Synapse, - schedule_for_cleanup: Callable[..., None], - ) -> File: - """Create a test file for submission tests.""" - import os - import tempfile - - # Create a temporary file - with tempfile.NamedTemporaryFile( - mode="w", delete=False, suffix=".txt" - ) as temp_file: - temp_file.write("This is test content for submission testing.") - temp_file_path = temp_file.name - - try: - file = File( - path=temp_file_path, - name=f"test_file_{uuid.uuid4()}.txt", - parent_id=test_project.id, - ).store(synapse_client=syn) - schedule_for_cleanup(file.id) - return file - finally: - # Clean up the temporary file - os.unlink(temp_file_path) - - def test_store_submission_successfully( - self, test_evaluation: Evaluation, test_file: File - ): - # WHEN I create a submission with valid data - submission = Submission( - entity_id=test_file.id, - evaluation_id=test_evaluation.id, - name=f"Test Submission {uuid.uuid4()}", - ) - created_submission = submission.store(synapse_client=self.syn) - self.schedule_for_cleanup(created_submission.id) - - # THEN the submission should be created successfully - assert created_submission.id is not None - assert created_submission.entity_id == test_file.id - assert created_submission.evaluation_id == test_evaluation.id - assert created_submission.name == submission.name - assert created_submission.user_id is not None - assert created_submission.created_on is not None - assert created_submission.version_number is not None - - def test_store_submission_without_entity_id(self, test_evaluation: Evaluation): - # WHEN I try to create a submission without entity_id - submission = Submission( - evaluation_id=test_evaluation.id, - name="Test Submission", - ) - - # THEN it should raise a ValueError - with pytest.raises(ValueError, match="entity_id is required"): - submission.store(synapse_client=self.syn) - - def test_store_submission_without_evaluation_id(self, test_file: File): - # WHEN I try to create a submission without evaluation_id - submission = Submission( - entity_id=test_file.id, - name="Test Submission", - ) - - # THEN it should raise a ValueError - with pytest.raises(ValueError, match="missing the 'evaluation_id' attribute"): - submission.store(synapse_client=self.syn) - - def test_store_submission_with_docker_repository(self, test_evaluation: Evaluation): - # GIVEN we would need a Docker repository entity (mocked for this test) - # This test demonstrates the expected behavior for Docker repository submissions - - # WHEN I create a submission for a Docker repository entity - # TODO: This would require a real Docker repository entity in a full integration test - # Jira: https://sagebionetworks.jira.com/browse/SYNPY-1720 - submission = Submission( - entity_id="syn123456789", # Would be a Docker repository ID - evaluation_id=test_evaluation.id, - name=f"Docker Submission {uuid.uuid4()}", - ) - - # THEN the submission should handle Docker-specific attributes - # (This test would need to be expanded with actual Docker repository setup) - assert submission.entity_id == "syn123456789" - assert submission.evaluation_id == test_evaluation.id - - -class TestSubmissionRetrieval: - @pytest.fixture(autouse=True, scope="function") - def init(self, syn: Synapse, schedule_for_cleanup: Callable[..., None]) -> None: - self.syn = syn - self.schedule_for_cleanup = schedule_for_cleanup - - @pytest.fixture(scope="function") - def test_project( - self, syn: Synapse, schedule_for_cleanup: Callable[..., None] - ) -> Project: - """Create a test project for submission tests.""" - project = Project(name=f"test_project_{uuid.uuid4()}").store(synapse_client=syn) - schedule_for_cleanup(project.id) - return project - - @pytest.fixture(scope="function") - def test_evaluation( - self, - test_project: Project, - syn: Synapse, - schedule_for_cleanup: Callable[..., None], - ) -> Evaluation: - """Create a test evaluation for submission tests.""" - evaluation = Evaluation( - name=f"test_evaluation_{uuid.uuid4()}", - description="A test evaluation for submission tests", - content_source=test_project.id, - submission_instructions_message="Please submit your results", - submission_receipt_message="Thank you!", - ) - created_evaluation = evaluation.store(synapse_client=syn) - schedule_for_cleanup(created_evaluation.id) - return created_evaluation - - @pytest.fixture(scope="function") - def test_file( - self, - test_project: Project, - syn: Synapse, - schedule_for_cleanup: Callable[..., None], - ) -> File: - """Create a test file for submission tests.""" - import os - import tempfile - - with tempfile.NamedTemporaryFile( - mode="w", delete=False, suffix=".txt" - ) as temp_file: - temp_file.write("This is test content for submission testing.") - temp_file_path = temp_file.name - - try: - file = File( - path=temp_file_path, - name=f"test_file_{uuid.uuid4()}.txt", - parent_id=test_project.id, - ).store(synapse_client=syn) - schedule_for_cleanup(file.id) - return file - finally: - os.unlink(temp_file_path) - - @pytest.fixture(scope="function") - def test_submission( - self, - test_evaluation: Evaluation, - test_file: File, - syn: Synapse, - schedule_for_cleanup: Callable[..., None], - ) -> Submission: - """Create a test submission for retrieval tests.""" - submission = Submission( - entity_id=test_file.id, - evaluation_id=test_evaluation.id, - name=f"Test Submission {uuid.uuid4()}", - ) - created_submission = submission.store(synapse_client=syn) - schedule_for_cleanup(created_submission.id) - return created_submission - - def test_get_submission_by_id( - self, test_submission: Submission, test_evaluation: Evaluation, test_file: File - ): - # WHEN I get a submission by ID - retrieved_submission = Submission(id=test_submission.id).get( - synapse_client=self.syn - ) - - # THEN the submission should be retrieved correctly - assert retrieved_submission.id == test_submission.id - assert retrieved_submission.entity_id == test_file.id - assert retrieved_submission.evaluation_id == test_evaluation.id - assert retrieved_submission.name == test_submission.name - assert retrieved_submission.user_id is not None - assert retrieved_submission.created_on is not None - - def test_get_evaluation_submissions( - self, test_evaluation: Evaluation, test_submission: Submission - ): - # WHEN I get all submissions for an evaluation - submissions = list( - Submission.get_evaluation_submissions( - evaluation_id=test_evaluation.id, synapse_client=self.syn - ) - ) - - # THEN I should get a list of submission objects - assert len(submissions) > 0 - assert all(isinstance(sub, Submission) for sub in submissions) - - # AND the test submission should be in the results - submission_ids = [sub.id for sub in submissions] - assert test_submission.id in submission_ids - - def test_get_evaluation_submissions_generator_behavior( - self, test_evaluation: Evaluation - ): - # WHEN I get submissions using the generator - submissions_generator = Submission.get_evaluation_submissions( - evaluation_id=test_evaluation.id, - synapse_client=self.syn, - ) - - # THEN I should be able to iterate through the results - submissions = [] - for submission in submissions_generator: - assert isinstance(submission, Submission) - submissions.append(submission) - - # AND all submissions should be valid Submission objects - assert all(isinstance(sub, Submission) for sub in submissions) - - def test_get_user_submissions(self, test_evaluation: Evaluation): - # WHEN I get submissions for the current user - submissions_generator = Submission.get_user_submissions( - evaluation_id=test_evaluation.id, synapse_client=self.syn - ) - - # THEN I should get a generator that yields Submission objects - submissions = list(submissions_generator) - # Note: Could be empty if user hasn't made submissions to this evaluation - assert all(isinstance(sub, Submission) for sub in submissions) - - def test_get_user_submissions_generator_behavior(self, test_evaluation: Evaluation): - # WHEN I get user submissions using the generator - submissions_generator = Submission.get_user_submissions( - evaluation_id=test_evaluation.id, - synapse_client=self.syn, - ) - - # THEN I should be able to iterate through the results - submissions = [] - for submission in submissions_generator: - assert isinstance(submission, Submission) - submissions.append(submission) - - # AND all submissions should be valid Submission objects - assert all(isinstance(sub, Submission) for sub in submissions) - - def test_get_submission_count(self, test_evaluation: Evaluation): - # WHEN I get the submission count for an evaluation - response = Submission.get_submission_count( - evaluation_id=test_evaluation.id, synapse_client=self.syn - ) - - # THEN I should get a count response - assert isinstance(response, int) - assert response >= 0 - - -class TestSubmissionDeletion: - @pytest.fixture(autouse=True, scope="function") - def init(self, syn: Synapse, schedule_for_cleanup: Callable[..., None]) -> None: - self.syn = syn - self.schedule_for_cleanup = schedule_for_cleanup - - @pytest.fixture(scope="function") - def test_project( - self, syn: Synapse, schedule_for_cleanup: Callable[..., None] - ) -> Project: - """Create a test project for submission tests.""" - project = Project(name=f"test_project_{uuid.uuid4()}").store(synapse_client=syn) - schedule_for_cleanup(project.id) - return project - - @pytest.fixture(scope="function") - def test_evaluation( - self, - test_project: Project, - syn: Synapse, - schedule_for_cleanup: Callable[..., None], - ) -> Evaluation: - """Create a test evaluation for submission tests.""" - evaluation = Evaluation( - name=f"test_evaluation_{uuid.uuid4()}", - description="A test evaluation for submission tests", - content_source=test_project.id, - submission_instructions_message="Please submit your results", - submission_receipt_message="Thank you!", - ) - created_evaluation = evaluation.store(synapse_client=syn) - schedule_for_cleanup(created_evaluation.id) - return created_evaluation - - @pytest.fixture(scope="function") - def test_file( - self, - test_project: Project, - syn: Synapse, - schedule_for_cleanup: Callable[..., None], - ) -> File: - """Create a test file for submission tests.""" - import os - import tempfile - - with tempfile.NamedTemporaryFile( - mode="w", delete=False, suffix=".txt" - ) as temp_file: - temp_file.write("This is test content for submission testing.") - temp_file_path = temp_file.name - - try: - file = File( - path=temp_file_path, - name=f"test_file_{uuid.uuid4()}.txt", - parent_id=test_project.id, - ).store(synapse_client=syn) - schedule_for_cleanup(file.id) - return file - finally: - os.unlink(temp_file_path) - - def test_delete_submission_successfully( - self, test_evaluation: Evaluation, test_file: File - ): - # GIVEN a submission - submission = Submission( - entity_id=test_file.id, - evaluation_id=test_evaluation.id, - name=f"Test Submission for Deletion {uuid.uuid4()}", - ) - created_submission = submission.store(synapse_client=self.syn) - - # WHEN I delete the submission - created_submission.delete(synapse_client=self.syn) - - # THEN attempting to retrieve it should raise an error - with pytest.raises(SynapseHTTPError): - Submission(id=created_submission.id).get(synapse_client=self.syn) - - def test_delete_submission_without_id(self): - # WHEN I try to delete a submission without an ID - submission = Submission(entity_id="syn123", evaluation_id="456") - - # THEN it should raise a ValueError - with pytest.raises(ValueError, match="must have an ID to delete"): - submission.delete(synapse_client=self.syn) - - -class TestSubmissionCancel: - @pytest.fixture(autouse=True, scope="function") - def init(self, syn: Synapse, schedule_for_cleanup: Callable[..., None]) -> None: - self.syn = syn - self.schedule_for_cleanup = schedule_for_cleanup - - @pytest.fixture(scope="function") - def test_project( - self, syn: Synapse, schedule_for_cleanup: Callable[..., None] - ) -> Project: - """Create a test project for submission tests.""" - project = Project(name=f"test_project_{uuid.uuid4()}").store(synapse_client=syn) - schedule_for_cleanup(project.id) - return project - - @pytest.fixture(scope="function") - def test_evaluation( - self, - test_project: Project, - syn: Synapse, - schedule_for_cleanup: Callable[..., None], - ) -> Evaluation: - """Create a test evaluation for submission tests.""" - evaluation = Evaluation( - name=f"test_evaluation_{uuid.uuid4()}", - description="A test evaluation for submission tests", - content_source=test_project.id, - submission_instructions_message="Please submit your results", - submission_receipt_message="Thank you!", - ) - created_evaluation = evaluation.store(synapse_client=syn) - schedule_for_cleanup(created_evaluation.id) - return created_evaluation - - @pytest.fixture(scope="function") - def test_file( - self, - test_project: Project, - syn: Synapse, - schedule_for_cleanup: Callable[..., None], - ) -> File: - """Create a test file for submission tests.""" - import os - import tempfile - - with tempfile.NamedTemporaryFile( - mode="w", delete=False, suffix=".txt" - ) as temp_file: - temp_file.write("This is test content for submission testing.") - temp_file_path = temp_file.name - - try: - file = File( - path=temp_file_path, - name=f"test_file_{uuid.uuid4()}.txt", - parent_id=test_project.id, - ).store(synapse_client=syn) - schedule_for_cleanup(file.id) - return file - finally: - os.unlink(temp_file_path) - - def test_cancel_submission_without_id(self): - # WHEN I try to cancel a submission without an ID - submission = Submission(entity_id="syn123", evaluation_id="456") - - # THEN it should raise a ValueError - with pytest.raises(ValueError, match="must have an ID to cancel"): - submission.cancel(synapse_client=self.syn) - - -class TestSubmissionValidation: - @pytest.fixture(autouse=True, scope="function") - def init(self, syn: Synapse, schedule_for_cleanup: Callable[..., None]) -> None: - self.syn = syn - self.schedule_for_cleanup = schedule_for_cleanup - - def test_get_submission_without_id(self): - # WHEN I try to get a submission without an ID - submission = Submission(entity_id="syn123", evaluation_id="456") - - # THEN it should raise a ValueError - with pytest.raises(ValueError, match="must have an ID to get"): - submission.get(synapse_client=self.syn) - - def test_to_synapse_request_missing_entity_id(self): - # WHEN I try to create a request without entity_id - submission = Submission(evaluation_id="456", name="Test") - - # THEN it should raise a ValueError - with pytest.raises( - ValueError, - match="Your submission object is missing the 'entity_id' attribute", - ): - submission.to_synapse_request() - - def test_to_synapse_request_missing_evaluation_id(self): - # WHEN I try to create a request without evaluation_id - submission = Submission(entity_id="syn123", name="Test") - - # THEN it should raise a ValueError - with pytest.raises(ValueError, match="missing the 'evaluation_id' attribute"): - submission.to_synapse_request() - - def test_to_synapse_request_valid_data(self): - # WHEN I create a request with valid required data - submission = Submission( - entity_id="syn123456", - evaluation_id="789", - name="Test Submission", - team_id="team123", - contributors=["user1", "user2"], - docker_repository_name="test/repo", - docker_digest="sha256:abc123", - ) - - request_body = submission.to_synapse_request() - - # THEN it should create a valid request body - assert request_body["entityId"] == "syn123456" - assert request_body["evaluationId"] == "789" - assert request_body["name"] == "Test Submission" - assert request_body["teamId"] == "team123" - assert request_body["contributors"] == ["user1", "user2"] - assert request_body["dockerRepositoryName"] == "test/repo" - assert request_body["dockerDigest"] == "sha256:abc123" - - def test_to_synapse_request_minimal_data(self): - # WHEN I create a request with only required data - submission = Submission(entity_id="syn123456", evaluation_id="789") - - request_body = submission.to_synapse_request() - - # THEN it should create a minimal request body - assert request_body["entityId"] == "syn123456" - assert request_body["evaluationId"] == "789" - assert "name" not in request_body - assert "teamId" not in request_body - assert "contributors" not in request_body - assert "dockerRepositoryName" not in request_body - assert "dockerDigest" not in request_body - - -class TestSubmissionDataMapping: - def test_fill_from_dict_complete_data(self): - # GIVEN a complete submission response from the REST API - api_response = { - "id": "123456", - "userId": "user123", - "submitterAlias": "testuser", - "entityId": "syn789", - "versionNumber": 1, - "evaluationId": "eval456", - "name": "Test Submission", - "createdOn": "2023-01-01T10:00:00.000Z", - "teamId": "team123", - "contributors": ["user1", "user2"], - "submissionStatus": {"status": "RECEIVED"}, - "entityBundleJSON": '{"entity": {"id": "syn789"}}', - "dockerRepositoryName": "test/repo", - "dockerDigest": "sha256:abc123", - } - - # WHEN I fill a submission object from the dict - submission = Submission() - submission.fill_from_dict(api_response) - - # THEN all fields should be mapped correctly - assert submission.id == "123456" - assert submission.user_id == "user123" - assert submission.submitter_alias == "testuser" - assert submission.entity_id == "syn789" - assert submission.version_number == 1 - assert submission.evaluation_id == "eval456" - assert submission.name == "Test Submission" - assert submission.created_on == "2023-01-01T10:00:00.000Z" - assert submission.team_id == "team123" - assert submission.contributors == ["user1", "user2"] - assert submission.submission_status == {"status": "RECEIVED"} - assert submission.entity_bundle_json == '{"entity": {"id": "syn789"}}' - assert submission.docker_repository_name == "test/repo" - assert submission.docker_digest == "sha256:abc123" - - def test_fill_from_dict_minimal_data(self): - # GIVEN a minimal submission response from the REST API - api_response = { - "id": "123456", - "entityId": "syn789", - "evaluationId": "eval456", - } - - # WHEN I fill a submission object from the dict - submission = Submission() - submission.fill_from_dict(api_response) - - # THEN required fields should be set and optional fields should have defaults - assert submission.id == "123456" - assert submission.entity_id == "syn789" - assert submission.evaluation_id == "eval456" - assert submission.user_id is None - assert submission.submitter_alias is None - assert submission.version_number is None - assert submission.name is None - assert submission.created_on is None - assert submission.team_id is None - assert submission.contributors == [] - assert submission.submission_status is None - assert submission.entity_bundle_json is None - assert submission.docker_repository_name is None - assert submission.docker_digest is None diff --git a/tests/integration/synapseclient/models/synchronous/test_submission_bundle.py b/tests/integration/synapseclient/models/synchronous/test_submission_bundle.py deleted file mode 100644 index ef961d4d4..000000000 --- a/tests/integration/synapseclient/models/synchronous/test_submission_bundle.py +++ /dev/null @@ -1,586 +0,0 @@ -"""Integration tests for the synapseclient.models.SubmissionBundle class.""" - -import uuid -from typing import Callable - -import pytest - -from synapseclient import Synapse -from synapseclient.core.exceptions import SynapseHTTPError -from synapseclient.models import ( - Evaluation, - File, - Project, - Submission, - SubmissionBundle, - SubmissionStatus, -) - - -class TestSubmissionBundleRetrieval: - """Tests for retrieving SubmissionBundle objects.""" - - @pytest.fixture(autouse=True, scope="function") - def init(self, syn: Synapse, schedule_for_cleanup: Callable[..., None]) -> None: - self.syn = syn - self.schedule_for_cleanup = schedule_for_cleanup - - @pytest.fixture(scope="function") - def test_project( - self, syn: Synapse, schedule_for_cleanup: Callable[..., None] - ) -> Project: - project = Project(name=f"test_project_{uuid.uuid4()}").store(synapse_client=syn) - schedule_for_cleanup(project.id) - return project - - @pytest.fixture(scope="function") - def test_evaluation( - self, - test_project: Project, - syn: Synapse, - schedule_for_cleanup: Callable[..., None], - ) -> Evaluation: - evaluation = Evaluation( - name=f"test_evaluation_{uuid.uuid4()}", - description="Test evaluation for SubmissionBundle testing", - content_source=test_project.id, - submission_instructions_message="Submit your files here", - submission_receipt_message="Thank you for your submission!", - ).store(synapse_client=syn) - schedule_for_cleanup(evaluation.id) - return evaluation - - @pytest.fixture(scope="function") - def test_file( - self, - test_project: Project, - syn: Synapse, - schedule_for_cleanup: Callable[..., None], - ) -> File: - file_content = f"Test file content for submission bundle tests {uuid.uuid4()}" - with open("test_file_for_submission_bundle.txt", "w") as f: - f.write(file_content) - - file_entity = File( - path="test_file_for_submission_bundle.txt", - name=f"test_submission_file_{uuid.uuid4()}", - parent_id=test_project.id, - ).store(synapse_client=syn) - schedule_for_cleanup(file_entity.id) - return file_entity - - @pytest.fixture(scope="function") - def test_submission( - self, - test_evaluation: Evaluation, - test_file: File, - syn: Synapse, - schedule_for_cleanup: Callable[..., None], - ) -> Submission: - submission = Submission( - name=f"test_submission_{uuid.uuid4()}", - entity_id=test_file.id, - evaluation_id=test_evaluation.id, - submitter_alias="test_user_bundle", - ).store(synapse_client=syn) - schedule_for_cleanup(submission.id) - return submission - - @pytest.fixture(scope="function") - def multiple_submissions( - self, - test_evaluation: Evaluation, - test_file: File, - syn: Synapse, - schedule_for_cleanup: Callable[..., None], - ) -> list[Submission]: - """Create multiple submissions for testing pagination and filtering.""" - submissions = [] - for i in range(3): - submission = Submission( - name=f"test_submission_{uuid.uuid4()}_{i}", - entity_id=test_file.id, - evaluation_id=test_evaluation.id, - submitter_alias=f"test_user_{i}", - ).store(synapse_client=syn) - schedule_for_cleanup(submission.id) - submissions.append(submission) - return submissions - - def test_get_evaluation_submission_bundles_basic( - self, test_evaluation: Evaluation, test_submission: Submission - ): - """Test getting submission bundles for an evaluation.""" - # WHEN I get submission bundles for an evaluation using generator - bundles = list( - SubmissionBundle.get_evaluation_submission_bundles( - evaluation_id=test_evaluation.id, - synapse_client=self.syn, - ) - ) - - # THEN I should get at least our test submission - assert len(bundles) >= 1 # At least our test submission - - # AND each bundle should have proper structure - found_test_bundle = False - for bundle in bundles: - assert isinstance(bundle, SubmissionBundle) - assert bundle.submission is not None - assert bundle.submission.id is not None - assert bundle.submission.evaluation_id == test_evaluation.id - - if bundle.submission.id == test_submission.id: - found_test_bundle = True - assert bundle.submission.entity_id == test_submission.entity_id - assert bundle.submission.name == test_submission.name - - # AND our test submission should be found - assert found_test_bundle, "Test submission should be found in bundles" - - def test_get_evaluation_submission_bundles_generator_behavior( - self, test_evaluation: Evaluation - ): - """Test that the generator returns SubmissionBundle objects correctly.""" - # WHEN I get submission bundles using the generator - bundles_generator = SubmissionBundle.get_evaluation_submission_bundles( - evaluation_id=test_evaluation.id, - synapse_client=self.syn, - ) - - # THEN I should be able to iterate through the results - bundles = [] - for bundle in bundles_generator: - assert isinstance(bundle, SubmissionBundle) - bundles.append(bundle) - - # AND all bundles should be valid SubmissionBundle objects - assert all(isinstance(bundle, SubmissionBundle) for bundle in bundles) - - def test_get_evaluation_submission_bundles_with_status_filter( - self, test_evaluation: Evaluation, test_submission: Submission - ): - """Test getting submission bundles filtered by status.""" - # WHEN I get submission bundles filtered by "RECEIVED" status - bundles = list( - SubmissionBundle.get_evaluation_submission_bundles( - evaluation_id=test_evaluation.id, - status="RECEIVED", - synapse_client=self.syn, - ) - ) - - # THEN the bundles should be retrieved - assert bundles is not None - - # AND all bundles should have RECEIVED status (if any exist) - for bundle in bundles: - if bundle.submission_status: - assert bundle.submission_status.status == "RECEIVED" - - # WHEN I attempt to get submission bundles with an invalid status - with pytest.raises(SynapseHTTPError) as exc_info: - list( - SubmissionBundle.get_evaluation_submission_bundles( - evaluation_id=test_evaluation.id, - status="NONEXISTENT_STATUS", - synapse_client=self.syn, - ) - ) - # THEN it should raise a SynapseHTTPError (400 for invalid enum) - assert exc_info.value.response.status_code == 400 - assert "No enum constant" in str(exc_info.value) - assert "NONEXISTENT_STATUS" in str(exc_info.value) - - def test_get_evaluation_submission_bundles_generator_behavior_with_multiple( - self, test_evaluation: Evaluation, multiple_submissions: list[Submission] - ): - """Test generator behavior when getting submission bundles with multiple submissions.""" - # WHEN I get submission bundles using the generator - bundles = list( - SubmissionBundle.get_evaluation_submission_bundles( - evaluation_id=test_evaluation.id, - synapse_client=self.syn, - ) - ) - - # THEN I should get all available bundles (at least the ones we created) - assert bundles is not None - assert len(bundles) >= len(multiple_submissions) - - # AND I should be able to iterate through the generator multiple times - # by creating a new generator each time - bundles_generator = SubmissionBundle.get_evaluation_submission_bundles( - evaluation_id=test_evaluation.id, - synapse_client=self.syn, - ) - - # THEN I should get the same bundles when iterating again - bundles_second_iteration = list(bundles_generator) - assert len(bundles_second_iteration) == len(bundles) - - # AND all created submissions should be found - bundle_submission_ids = { - bundle.submission.id for bundle in bundles if bundle.submission - } - created_submission_ids = {sub.id for sub in multiple_submissions} - assert created_submission_ids.issubset( - bundle_submission_ids - ), "All created submissions should be found in bundles" - - def test_get_evaluation_submission_bundles_invalid_evaluation(self): - """Test getting submission bundles for invalid evaluation ID.""" - # WHEN I try to get submission bundles for a non-existent evaluation - with pytest.raises(SynapseHTTPError) as exc_info: - list( - SubmissionBundle.get_evaluation_submission_bundles( - evaluation_id="syn999999999999", - synapse_client=self.syn, - ) - ) - - # THEN it should raise a SynapseHTTPError (likely 403 or 404) - assert exc_info.value.response.status_code in [403, 404] - - def test_get_user_submission_bundles_basic( - self, test_evaluation: Evaluation, test_submission: Submission - ): - """Test getting user submission bundles for an evaluation.""" - # WHEN I get user submission bundles for an evaluation - bundles = list( - SubmissionBundle.get_user_submission_bundles( - evaluation_id=test_evaluation.id, - synapse_client=self.syn, - ) - ) - - # THEN the bundles should be retrieved - assert bundles is not None - assert len(bundles) >= 1 # At least our test submission - - # AND each bundle should have proper structure - found_test_bundle = False - for bundle in bundles: - assert isinstance(bundle, SubmissionBundle) - assert bundle.submission is not None - assert bundle.submission.id is not None - assert bundle.submission.evaluation_id == test_evaluation.id - - if bundle.submission.id == test_submission.id: - found_test_bundle = True - assert bundle.submission.entity_id == test_submission.entity_id - assert bundle.submission.name == test_submission.name - - # AND our test submission should be found - assert found_test_bundle, "Test submission should be found in user bundles" - - def test_get_user_submission_bundles_generator_behavior_with_multiple( - self, test_evaluation: Evaluation, multiple_submissions: list[Submission] - ): - """Test generator behavior when getting user submission bundles with multiple submissions.""" - # WHEN I get user submission bundles using the generator - bundles = list( - SubmissionBundle.get_user_submission_bundles( - evaluation_id=test_evaluation.id, - synapse_client=self.syn, - ) - ) - - # THEN I should get all available bundles (at least the ones we created) - assert bundles is not None - assert len(bundles) >= len(multiple_submissions) - - # AND I should be able to iterate through the generator multiple times - # by creating a new generator each time - bundles_generator = SubmissionBundle.get_user_submission_bundles( - evaluation_id=test_evaluation.id, - synapse_client=self.syn, - ) - - # THEN I should get the same bundles when iterating again - bundles_second_iteration = list(bundles_generator) - assert len(bundles_second_iteration) == len(bundles) - - # AND all created submissions should be found - bundle_submission_ids = { - bundle.submission.id for bundle in bundles if bundle.submission - } - created_submission_ids = {sub.id for sub in multiple_submissions} - assert created_submission_ids.issubset( - bundle_submission_ids - ), "All created submissions should be found in user bundles" - - -class TestSubmissionBundleDataIntegrity: - """Tests for data integrity and relationships in SubmissionBundle objects.""" - - @pytest.fixture(autouse=True, scope="function") - def init(self, syn: Synapse, schedule_for_cleanup: Callable[..., None]) -> None: - self.syn = syn - self.schedule_for_cleanup = schedule_for_cleanup - - @pytest.fixture(scope="function") - def test_project( - self, syn: Synapse, schedule_for_cleanup: Callable[..., None] - ) -> Project: - project = Project(name=f"test_project_{uuid.uuid4()}").store(synapse_client=syn) - schedule_for_cleanup(project.id) - return project - - @pytest.fixture(scope="function") - def test_evaluation( - self, - test_project: Project, - syn: Synapse, - schedule_for_cleanup: Callable[..., None], - ) -> Evaluation: - evaluation = Evaluation( - name=f"test_evaluation_{uuid.uuid4()}", - description="Test evaluation for data integrity testing", - content_source=test_project.id, - submission_instructions_message="Submit your files here", - submission_receipt_message="Thank you for your submission!", - ).store(synapse_client=syn) - schedule_for_cleanup(evaluation.id) - return evaluation - - @pytest.fixture(scope="function") - def test_file( - self, - test_project: Project, - syn: Synapse, - schedule_for_cleanup: Callable[..., None], - ) -> File: - file_content = f"Test file content for data integrity tests {uuid.uuid4()}" - with open("test_file_for_data_integrity.txt", "w") as f: - f.write(file_content) - - file_entity = File( - path="test_file_for_data_integrity.txt", - name=f"test_integrity_file_{uuid.uuid4()}", - parent_id=test_project.id, - ).store(synapse_client=syn) - schedule_for_cleanup(file_entity.id) - return file_entity - - @pytest.fixture(scope="function") - def test_submission( - self, - test_evaluation: Evaluation, - test_file: File, - syn: Synapse, - schedule_for_cleanup: Callable[..., None], - ) -> Submission: - submission = Submission( - name=f"test_submission_{uuid.uuid4()}", - entity_id=test_file.id, - evaluation_id=test_evaluation.id, - submitter_alias="test_user_integrity", - ).store(synapse_client=syn) - schedule_for_cleanup(submission.id) - return submission - - def test_submission_bundle_data_consistency( - self, test_evaluation: Evaluation, test_submission: Submission, test_file: File - ): - """Test that submission bundles maintain data consistency between submission and status.""" - # WHEN I get submission bundles for the evaluation - bundles = list( - SubmissionBundle.get_evaluation_submission_bundles( - evaluation_id=test_evaluation.id, - synapse_client=self.syn, - ) - ) - - # THEN I should find our test submission - test_bundle = None - for bundle in bundles: - if bundle.submission and bundle.submission.id == test_submission.id: - test_bundle = bundle - break - - assert test_bundle is not None, "Test submission bundle should be found" - - # AND the submission data should be consistent - assert test_bundle.submission.id == test_submission.id - assert test_bundle.submission.entity_id == test_file.id - assert test_bundle.submission.evaluation_id == test_evaluation.id - assert test_bundle.submission.name == test_submission.name - - # AND if there's a submission status, it should reference the same entities - if test_bundle.submission_status: - assert test_bundle.submission_status.id == test_submission.id - assert test_bundle.submission_status.entity_id == test_file.id - - def test_submission_bundle_status_updates_reflected( - self, test_evaluation: Evaluation, test_submission: Submission - ): - """Test that submission status updates are reflected in bundles.""" - # GIVEN a submission status that I can update - submission_status = SubmissionStatus(id=test_submission.id).get( - synapse_client=self.syn - ) - original_status = submission_status.status - - # WHEN I update the submission status - submission_status.status = "VALIDATED" - submission_status.submission_annotations = { - "test_score": 95.5, - "test_feedback": "Excellent work!", - } - updated_status = submission_status.store(synapse_client=self.syn) - - # AND I get submission bundles again - bundles = list( - SubmissionBundle.get_evaluation_submission_bundles( - evaluation_id=test_evaluation.id, - synapse_client=self.syn, - ) - ) - - # THEN the bundle should reflect the updated status - test_bundle = None - for bundle in bundles: - if bundle.submission and bundle.submission.id == test_submission.id: - test_bundle = bundle - break - - assert test_bundle is not None - assert test_bundle.submission_status is not None - assert test_bundle.submission_status.status == "VALIDATED" - assert test_bundle.submission_status.submission_annotations is not None - assert "test_score" in test_bundle.submission_status.submission_annotations - assert test_bundle.submission_status.submission_annotations["test_score"] == [ - 95.5 - ] - - # CLEANUP: Reset the status back to original - submission_status.status = original_status - submission_status.submission_annotations = {} - submission_status.store(synapse_client=self.syn) - - -class TestSubmissionBundleEdgeCases: - """Tests for edge cases and error handling in SubmissionBundle operations.""" - - @pytest.fixture(autouse=True, scope="function") - def init(self, syn: Synapse, schedule_for_cleanup: Callable[..., None]) -> None: - self.syn = syn - self.schedule_for_cleanup = schedule_for_cleanup - - @pytest.fixture(scope="function") - def test_project( - self, syn: Synapse, schedule_for_cleanup: Callable[..., None] - ) -> Project: - project = Project(name=f"test_project_{uuid.uuid4()}").store(synapse_client=syn) - schedule_for_cleanup(project.id) - return project - - @pytest.fixture(scope="function") - def test_evaluation( - self, - test_project: Project, - syn: Synapse, - schedule_for_cleanup: Callable[..., None], - ) -> Evaluation: - evaluation = Evaluation( - name=f"test_evaluation_{uuid.uuid4()}", - description="Test evaluation for edge case testing", - content_source=test_project.id, - submission_instructions_message="Submit your files here", - submission_receipt_message="Thank you for your submission!", - ).store(synapse_client=syn) - schedule_for_cleanup(evaluation.id) - return evaluation - - def test_get_evaluation_submission_bundles_empty_evaluation( - self, test_evaluation: Evaluation - ): - """Test getting submission bundles from an evaluation with no submissions.""" - # WHEN I get submission bundles from an evaluation with no submissions - bundles = list( - SubmissionBundle.get_evaluation_submission_bundles( - evaluation_id=test_evaluation.id, - synapse_client=self.syn, - ) - ) - - # THEN it should return an empty list (not None or error) - assert bundles is not None - assert isinstance(bundles, list) - assert len(bundles) == 0 - - def test_get_user_submission_bundles_empty_evaluation( - self, test_evaluation: Evaluation - ): - """Test getting user submission bundles from an evaluation with no submissions.""" - # WHEN I get user submission bundles from an evaluation with no submissions - bundles = list( - SubmissionBundle.get_user_submission_bundles( - evaluation_id=test_evaluation.id, - synapse_client=self.syn, - ) - ) - - # THEN it should return an empty list (not None or error) - assert bundles is not None - assert isinstance(bundles, list) - assert len(bundles) == 0 - - def test_get_evaluation_submission_bundles_generator_consistency( - self, test_evaluation: Evaluation - ): - """Test that the generator produces consistent results across multiple iterations.""" - # WHEN I request bundles using the generator - bundles = list( - SubmissionBundle.get_evaluation_submission_bundles( - evaluation_id=test_evaluation.id, - synapse_client=self.syn, - ) - ) - - # THEN it should work without error - assert bundles is not None - assert isinstance(bundles, list) - # The actual count doesn't matter since the evaluation is empty - - def test_get_user_submission_bundles_generator_empty_results( - self, test_evaluation: Evaluation - ): - """Test that user submission bundles generator handles empty results correctly.""" - # WHEN I request bundles from an empty evaluation - bundles = list( - SubmissionBundle.get_user_submission_bundles( - evaluation_id=test_evaluation.id, - synapse_client=self.syn, - ) - ) - - # THEN it should return an empty list (not error) - assert bundles is not None - assert isinstance(bundles, list) - assert len(bundles) == 0 - - def test_get_submission_bundles_with_default_parameters( - self, test_evaluation: Evaluation - ): - """Test that default parameters work correctly.""" - # WHEN I call methods without optional parameters - eval_bundles = list( - SubmissionBundle.get_evaluation_submission_bundles( - evaluation_id=test_evaluation.id, - synapse_client=self.syn, - ) - ) - user_bundles = list( - SubmissionBundle.get_user_submission_bundles( - evaluation_id=test_evaluation.id, - synapse_client=self.syn, - ) - ) - - # THEN both should work with default values - assert eval_bundles is not None - assert user_bundles is not None - assert isinstance(eval_bundles, list) - assert isinstance(user_bundles, list) diff --git a/tests/integration/synapseclient/models/synchronous/test_submission_status.py b/tests/integration/synapseclient/models/synchronous/test_submission_status.py deleted file mode 100644 index e401b5a96..000000000 --- a/tests/integration/synapseclient/models/synchronous/test_submission_status.py +++ /dev/null @@ -1,876 +0,0 @@ -"""Integration tests for the synapseclient.models.SubmissionStatus class.""" - -import os -import tempfile -import uuid -from typing import Callable - -import pytest - -from synapseclient import Synapse -from synapseclient.annotations import from_submission_status_annotations -from synapseclient.core.exceptions import SynapseHTTPError -from synapseclient.models import Evaluation, File, Project, Submission, SubmissionStatus - -# Based on API reference: https://rest-docs.synapse.org/rest/org/sagebionetworks/evaluation/model/SubmissionStatusEnum.html -POSSIBLE_STATUSES = [ - "OPEN", - "CLOSED", - "SCORED", - "RECEIVED", - "VALIDATED", - "INVALID", - "REJECTED", - "ACCEPTED", - "EVALUATION_IN_PROGRESS", -] - - -class TestSubmissionStatusRetrieval: - """Tests for retrieving SubmissionStatus objects.""" - - @pytest.fixture(autouse=True, scope="function") - def init(self, syn: Synapse, schedule_for_cleanup: Callable[..., None]) -> None: - self.syn = syn - self.schedule_for_cleanup = schedule_for_cleanup - - @pytest.fixture(scope="function") - def test_evaluation( - self, - project_model: Project, - syn: Synapse, - schedule_for_cleanup: Callable[..., None], - ) -> Evaluation: - """Create a test evaluation for submission status tests.""" - evaluation = Evaluation( - name=f"test_evaluation_{uuid.uuid4()}", - description="A test evaluation for submission status tests", - content_source=project_model.id, - submission_instructions_message="Please submit your results", - submission_receipt_message="Thank you!", - ) - created_evaluation = evaluation.store(synapse_client=syn) - schedule_for_cleanup(created_evaluation.id) - return created_evaluation - - @pytest.fixture(scope="function") - def test_file( - self, - project_model: Project, - syn: Synapse, - schedule_for_cleanup: Callable[..., None], - ) -> File: - """Create a test file for submission status tests.""" - # Create a temporary file - with tempfile.NamedTemporaryFile( - mode="w", delete=False, suffix=".txt" - ) as temp_file: - temp_file.write("This is test content for submission status testing.") - temp_file_path = temp_file.name - - try: - file = File( - path=temp_file_path, - name=f"test_file_{uuid.uuid4()}.txt", - parent_id=project_model.id, - ).store(synapse_client=syn) - schedule_for_cleanup(file.id) - return file - finally: - # Clean up the temporary file - os.unlink(temp_file_path) - - @pytest.fixture(scope="function") - def test_submission( - self, - test_evaluation: Evaluation, - test_file: File, - syn: Synapse, - schedule_for_cleanup: Callable[..., None], - ) -> Submission: - """Create a test submission for status tests.""" - submission = Submission( - entity_id=test_file.id, - evaluation_id=test_evaluation.id, - name=f"Test Submission {uuid.uuid4()}", - ) - created_submission = submission.store(synapse_client=syn) - schedule_for_cleanup(created_submission.id) - return created_submission - - def test_get_submission_status_by_id( - self, test_submission: Submission, test_evaluation: Evaluation - ): - """Test retrieving a submission status by ID.""" - # WHEN I get a submission status by ID - submission_status = SubmissionStatus(id=test_submission.id).get( - synapse_client=self.syn - ) - - # THEN the submission status should be retrieved correctly - assert submission_status.id == test_submission.id - assert submission_status.entity_id == test_submission.entity_id - assert submission_status.status in POSSIBLE_STATUSES - assert submission_status.etag is not None - assert submission_status.status_version is not None - assert submission_status.modified_on is not None - - def test_get_submission_status_without_id(self): - """Test that getting a submission status without ID raises ValueError.""" - # WHEN I try to get a submission status without an ID - submission_status = SubmissionStatus() - - # THEN it should raise a ValueError - with pytest.raises( - ValueError, match="The submission status must have an ID to get" - ): - submission_status.get(synapse_client=self.syn) - - def test_get_submission_status_with_invalid_id(self): - """Test that getting a submission status with invalid ID raises exception.""" - # WHEN I try to get a submission status with an invalid ID - submission_status = SubmissionStatus(id="syn999999999999") - - # THEN it should raise a SynapseHTTPError (404) - with pytest.raises(SynapseHTTPError): - submission_status.get(synapse_client=self.syn) - - -class TestSubmissionStatusUpdates: - """Tests for updating SubmissionStatus objects.""" - - @pytest.fixture(autouse=True, scope="function") - def init(self, syn: Synapse, schedule_for_cleanup: Callable[..., None]) -> None: - self.syn = syn - self.schedule_for_cleanup = schedule_for_cleanup - - @pytest.fixture(scope="function") - def test_evaluation( - self, - project_model: Project, - syn: Synapse, - schedule_for_cleanup: Callable[..., None], - ) -> Evaluation: - """Create a test evaluation for submission status tests.""" - evaluation = Evaluation( - name=f"test_evaluation_{uuid.uuid4()}", - description="A test evaluation for submission status tests", - content_source=project_model.id, - submission_instructions_message="Please submit your results", - submission_receipt_message="Thank you!", - ) - created_evaluation = evaluation.store(synapse_client=syn) - schedule_for_cleanup(created_evaluation.id) - return created_evaluation - - @pytest.fixture(scope="function") - def test_file( - self, - project_model: Project, - syn: Synapse, - schedule_for_cleanup: Callable[..., None], - ) -> File: - """Create a test file for submission status tests.""" - # Create a temporary file - with tempfile.NamedTemporaryFile( - mode="w", delete=False, suffix=".txt" - ) as temp_file: - temp_file.write("This is test content for submission status testing.") - temp_file_path = temp_file.name - - try: - file = File( - path=temp_file_path, - name=f"test_file_{uuid.uuid4()}.txt", - parent_id=project_model.id, - ).store(synapse_client=syn) - schedule_for_cleanup(file.id) - return file - finally: - # Clean up the temporary file - os.unlink(temp_file_path) - - @pytest.fixture(scope="function") - def test_submission( - self, - test_evaluation: Evaluation, - test_file: File, - syn: Synapse, - schedule_for_cleanup: Callable[..., None], - ) -> Submission: - """Create a test submission for status tests.""" - submission = Submission( - entity_id=test_file.id, - evaluation_id=test_evaluation.id, - name=f"Test Submission {uuid.uuid4()}", - ) - created_submission = submission.store(synapse_client=syn) - schedule_for_cleanup(created_submission.id) - return created_submission - - @pytest.fixture(scope="function") - def test_submission_status(self, test_submission: Submission) -> SubmissionStatus: - """Create a test submission status by getting the existing one.""" - submission_status = SubmissionStatus(id=test_submission.id).get( - synapse_client=self.syn - ) - return submission_status - - def test_store_submission_status_with_status_change( - self, test_submission_status: SubmissionStatus - ): - """Test updating a submission status with a status change.""" - # GIVEN a submission status that exists - original_status = test_submission_status.status - original_etag = test_submission_status.etag - original_status_version = test_submission_status.status_version - - # WHEN I update the status - test_submission_status.status = "VALIDATED" - updated_status = test_submission_status.store(synapse_client=self.syn) - - # THEN the submission status should be updated - assert updated_status.id == test_submission_status.id - assert updated_status.status == "VALIDATED" - assert updated_status.status != original_status - assert updated_status.etag != original_etag # etag should change - assert updated_status.status_version > original_status_version - - def test_store_submission_status_with_submission_annotations( - self, test_submission_status: SubmissionStatus - ): - """Test updating a submission status with submission annotations.""" - # WHEN I add submission annotations and store - test_submission_status.submission_annotations = { - "score": 85.5, - "feedback": "Good work!", - } - updated_status = test_submission_status.store(synapse_client=self.syn) - - # THEN the submission annotations should be saved - assert updated_status.submission_annotations is not None - assert "score" in updated_status.submission_annotations - assert updated_status.submission_annotations["score"] == [85.5] - assert updated_status.submission_annotations["feedback"] == ["Good work!"] - - def test_store_submission_status_with_legacy_annotations( - self, test_submission_status: SubmissionStatus - ): - """Test updating a submission status with legacy annotations.""" - # WHEN I add legacy annotations and store - test_submission_status.annotations = { - "internal_score": 92.3, - "reviewer_notes": "Excellent submission", - } - updated_status = test_submission_status.store(synapse_client=self.syn) - assert updated_status.annotations is not None - - converted_annotations = from_submission_status_annotations( - updated_status.annotations - ) - - # THEN the legacy annotations should be saved - assert "internal_score" in converted_annotations - assert converted_annotations["internal_score"] == 92.3 - assert converted_annotations["reviewer_notes"] == "Excellent submission" - - def test_store_submission_status_with_combined_annotations( - self, test_submission_status: SubmissionStatus - ): - """Test updating a submission status with both types of annotations.""" - # WHEN I add both submission and legacy annotations - test_submission_status.submission_annotations = { - "public_score": 78.0, - "category": "Bronze", - } - test_submission_status.annotations = { - "internal_review": True, - "notes": "Needs minor improvements", - } - updated_status = test_submission_status.store(synapse_client=self.syn) - - # THEN both types of annotations should be saved - assert updated_status.submission_annotations is not None - assert "public_score" in updated_status.submission_annotations - assert updated_status.submission_annotations["public_score"] == [78.0] - - assert updated_status.annotations is not None - converted_annotations = from_submission_status_annotations( - updated_status.annotations - ) - assert "internal_review" in converted_annotations - assert converted_annotations["internal_review"] == "true" - - def test_store_submission_status_with_private_annotations_false( - self, test_submission_status: SubmissionStatus - ): - """Test updating a submission status with private_status_annotations set to False.""" - # WHEN I add legacy annotations with private_status_annotations set to False - test_submission_status.annotations = { - "public_internal_score": 88.5, - "public_notes": "This should be visible", - } - test_submission_status.private_status_annotations = False - - # WHEN I store the submission status - updated_status = test_submission_status.store(synapse_client=self.syn) - - # THEN they should be properly stored - assert updated_status.annotations is not None - converted_annotations = from_submission_status_annotations( - updated_status.annotations - ) - assert "public_internal_score" in converted_annotations - assert converted_annotations["public_internal_score"] == 88.5 - assert converted_annotations["public_notes"] == "This should be visible" - - # AND the annotations should be marked as not private - for annos_type in ["stringAnnos", "doubleAnnos"]: - annotations = updated_status.annotations[annos_type] - assert all(not anno["isPrivate"] for anno in annotations) - - def test_store_submission_status_with_private_annotations_true( - self, test_submission_status: SubmissionStatus - ): - """Test updating a submission status with private_status_annotations set to True (default).""" - # WHEN I add legacy annotations with private_status_annotations set to True (default) - test_submission_status.annotations = { - "private_internal_score": 95.0, - "private_notes": "This should be private", - } - test_submission_status.private_status_annotations = True - - # AND I create the request body to inspect it - request_body = test_submission_status.to_synapse_request( - synapse_client=self.syn - ) - - # WHEN I store the submission status - updated_status = test_submission_status.store(synapse_client=self.syn) - - # THEN they should be properly stored - assert updated_status.annotations is not None - converted_annotations = from_submission_status_annotations( - updated_status.annotations - ) - assert "private_internal_score" in converted_annotations - assert converted_annotations["private_internal_score"] == 95.0 - assert converted_annotations["private_notes"] == "This should be private" - - # AND the annotations should be marked as private - for annos_type in ["stringAnnos", "doubleAnnos"]: - annotations = updated_status.annotations[annos_type] - print(annotations) - assert all(anno["isPrivate"] for anno in annotations) - - def test_store_submission_status_without_id(self): - """Test that storing a submission status without ID raises ValueError.""" - # WHEN I try to store a submission status without an ID - submission_status = SubmissionStatus(status="SCORED") - - # THEN it should raise a ValueError - with pytest.raises( - ValueError, match="The submission status must have an ID to update" - ): - submission_status.store(synapse_client=self.syn) - - def test_store_submission_status_without_changes( - self, test_submission_status: SubmissionStatus - ): - """Test that storing a submission status without changes shows warning.""" - # GIVEN a submission status that hasn't been modified - # (it already has _last_persistent_instance set from get()) - - # WHEN I try to store it without making changes - result = test_submission_status.store(synapse_client=self.syn) - - # THEN it should return the same instance (no update sent to Synapse) - assert result is test_submission_status - - def test_store_submission_status_change_tracking( - self, test_submission_status: SubmissionStatus - ): - """Test that change tracking works correctly.""" - # GIVEN a submission status that was retrieved (has_changed should be False) - assert not test_submission_status.has_changed - - # WHEN I make a change - test_submission_status.status = "SCORED" - - # THEN has_changed should be True - assert test_submission_status.has_changed - - # WHEN I store the changes - updated_status = test_submission_status.store(synapse_client=self.syn) - - # THEN has_changed should be False again - assert not updated_status.has_changed - - def test_has_changed_property_edge_cases( - self, test_submission_status: SubmissionStatus - ): - """Test the has_changed property with various edge cases and detailed scenarios.""" - # GIVEN a submission status that was just retrieved - assert not test_submission_status.has_changed - original_annotations = ( - test_submission_status.annotations.copy() - if test_submission_status.annotations - else {} - ) - - # WHEN I modify only annotations (not submission_annotations) - test_submission_status.annotations = {"test_key": "test_value"} - - # THEN has_changed should be True - assert test_submission_status.has_changed - - # WHEN I reset annotations to the original value (should be the same as the persistent instance) - test_submission_status.annotations = original_annotations - - # THEN has_changed should be False (same as original) - assert not test_submission_status.has_changed - - # WHEN I add a different annotation value - test_submission_status.annotations = {"different_key": "different_value"} - - # THEN has_changed should be True - assert test_submission_status.has_changed - - # WHEN I store and get a fresh copy - updated_status = test_submission_status.store(synapse_client=self.syn) - fresh_status = SubmissionStatus(id=updated_status.id).get( - synapse_client=self.syn - ) - - # THEN the fresh copy should not have changes - assert not fresh_status.has_changed - - # WHEN I modify only submission_annotations - fresh_status.submission_annotations = {"new_key": ["new_value"]} - - # THEN has_changed should be True - assert fresh_status.has_changed - - # WHEN I modify a scalar field - fresh_status.status = "VALIDATED" - - # THEN has_changed should still be True - assert fresh_status.has_changed - - -class TestSubmissionStatusBulkOperations: - """Tests for bulk SubmissionStatus operations.""" - - @pytest.fixture(autouse=True, scope="function") - def init(self, syn: Synapse, schedule_for_cleanup: Callable[..., None]) -> None: - self.syn = syn - self.schedule_for_cleanup = schedule_for_cleanup - - @pytest.fixture(scope="function") - def test_evaluation( - self, - project_model: Project, - syn: Synapse, - schedule_for_cleanup: Callable[..., None], - ) -> Evaluation: - """Create a test evaluation for submission status tests.""" - evaluation = Evaluation( - name=f"test_evaluation_{uuid.uuid4()}", - description="A test evaluation for submission status tests", - content_source=project_model.id, - submission_instructions_message="Please submit your results", - submission_receipt_message="Thank you!", - ) - created_evaluation = evaluation.store(synapse_client=syn) - schedule_for_cleanup(created_evaluation.id) - return created_evaluation - - @pytest.fixture(scope="function") - def test_files( - self, - project_model: Project, - syn: Synapse, - schedule_for_cleanup: Callable[..., None], - ) -> list[File]: - """Create multiple test files for submission status tests.""" - files = [] - for i in range(3): - # Create a temporary file - with tempfile.NamedTemporaryFile( - mode="w", delete=False, suffix=".txt" - ) as temp_file: - temp_file.write( - f"This is test content {i} for submission status testing." - ) - temp_file_path = temp_file.name - - try: - file = File( - path=temp_file_path, - name=f"test_file_{i}_{uuid.uuid4()}.txt", - parent_id=project_model.id, - ).store(synapse_client=syn) - schedule_for_cleanup(file.id) - files.append(file) - finally: - # Clean up the temporary file - os.unlink(temp_file_path) - return files - - @pytest.fixture(scope="function") - def test_submissions( - self, - test_evaluation: Evaluation, - test_files: list[File], - syn: Synapse, - schedule_for_cleanup: Callable[..., None], - ) -> list[Submission]: - """Create multiple test submissions for status tests.""" - submissions = [] - for i, file in enumerate(test_files): - submission = Submission( - entity_id=file.id, - evaluation_id=test_evaluation.id, - name=f"Test Submission {i} {uuid.uuid4()}", - ) - created_submission = submission.store(synapse_client=syn) - schedule_for_cleanup(created_submission.id) - submissions.append(created_submission) - return submissions - - def test_get_all_submission_statuses( - self, test_evaluation: Evaluation, test_submissions: list[Submission] - ): - """Test getting all submission statuses for an evaluation.""" - # WHEN I get all submission statuses for the evaluation - statuses = SubmissionStatus.get_all_submission_statuses( - evaluation_id=test_evaluation.id, - synapse_client=self.syn, - ) - - # THEN I should get submission statuses for all submissions - assert len(statuses) >= len(test_submissions) - status_ids = [status.id for status in statuses] - - # AND all test submissions should have their statuses in the results - for submission in test_submissions: - assert submission.id in status_ids - - # AND each status should have proper attributes - for status in statuses: - assert status.id is not None - assert status.status is not None - assert status.etag is not None - - def test_get_all_submission_statuses_with_status_filter( - self, test_evaluation: Evaluation, test_submissions: list[Submission] - ): - """Test getting submission statuses with status filter.""" - # WHEN I get submission statuses filtered by status - statuses = SubmissionStatus.get_all_submission_statuses( - evaluation_id=test_evaluation.id, - status="RECEIVED", - synapse_client=self.syn, - ) - - # THEN I should only get statuses with the specified status - for status in statuses: - assert status.status == "RECEIVED" - - def test_get_all_submission_statuses_with_pagination( - self, test_evaluation: Evaluation, test_submissions: list[Submission] - ): - """Test getting submission statuses with pagination.""" - # WHEN I get submission statuses with pagination - statuses_page1 = SubmissionStatus.get_all_submission_statuses( - evaluation_id=test_evaluation.id, - limit=2, - offset=0, - synapse_client=self.syn, - ) - - # THEN I should get at most 2 statuses - assert len(statuses_page1) <= 2 - - # WHEN I get the next page - statuses_page2 = SubmissionStatus.get_all_submission_statuses( - evaluation_id=test_evaluation.id, - limit=2, - offset=2, - synapse_client=self.syn, - ) - - # THEN the results should be different (assuming more than 2 submissions exist) - if len(statuses_page1) == 2 and len(statuses_page2) > 0: - page1_ids = {status.id for status in statuses_page1} - page2_ids = {status.id for status in statuses_page2} - assert page1_ids != page2_ids # Should be different sets - - def test_batch_update_submission_statuses( - self, test_evaluation: Evaluation, test_submissions: list[Submission] - ): - """Test batch updating multiple submission statuses.""" - # GIVEN multiple submission statuses - statuses = [] - for submission in test_submissions: - status = SubmissionStatus(id=submission.id).get(synapse_client=self.syn) - # Update each status - status.status = "VALIDATED" - status.submission_annotations = { - "batch_score": 90.0 + (len(statuses) * 2), - "batch_processed": True, - } - statuses.append(status) - - # WHEN I batch update the statuses - response = SubmissionStatus.batch_update_submission_statuses( - evaluation_id=test_evaluation.id, - statuses=statuses, - synapse_client=self.syn, - ) - - # THEN the batch update should succeed - assert response is not None - assert "batchToken" in response or response == {} # Response format may vary - - # AND I should be able to verify the updates by retrieving the statuses - for original_status in statuses: - updated_status = SubmissionStatus(id=original_status.id).get( - synapse_client=self.syn - ) - assert updated_status.status == "VALIDATED" - converted_submission_annotations = from_submission_status_annotations( - updated_status.submission_annotations - ) - assert "batch_score" in converted_submission_annotations - assert converted_submission_annotations["batch_processed"] == ["true"] - - -class TestSubmissionStatusCancellation: - """Tests for SubmissionStatus cancellation functionality.""" - - @pytest.fixture(autouse=True, scope="function") - def init(self, syn: Synapse, schedule_for_cleanup: Callable[..., None]) -> None: - self.syn = syn - self.schedule_for_cleanup = schedule_for_cleanup - - @pytest.fixture(scope="function") - def test_evaluation( - self, - project_model: Project, - syn: Synapse, - schedule_for_cleanup: Callable[..., None], - ) -> Evaluation: - """Create a test evaluation for submission status tests.""" - evaluation = Evaluation( - name=f"test_evaluation_{uuid.uuid4()}", - description="A test evaluation for submission status tests", - content_source=project_model.id, - submission_instructions_message="Please submit your results", - submission_receipt_message="Thank you!", - ) - created_evaluation = evaluation.store(synapse_client=syn) - schedule_for_cleanup(created_evaluation.id) - return created_evaluation - - @pytest.fixture(scope="function") - def test_file( - self, - project_model: Project, - syn: Synapse, - schedule_for_cleanup: Callable[..., None], - ) -> File: - """Create a test file for submission status tests.""" - # Create a temporary file - with tempfile.NamedTemporaryFile( - mode="w", delete=False, suffix=".txt" - ) as temp_file: - temp_file.write("This is test content for submission status testing.") - temp_file_path = temp_file.name - - try: - file = File( - path=temp_file_path, - name=f"test_file_{uuid.uuid4()}.txt", - parent_id=project_model.id, - ).store(synapse_client=syn) - schedule_for_cleanup(file.id) - return file - finally: - # Clean up the temporary file - os.unlink(temp_file_path) - - @pytest.fixture(scope="function") - def test_submission( - self, - test_evaluation: Evaluation, - test_file: File, - syn: Synapse, - schedule_for_cleanup: Callable[..., None], - ) -> Submission: - """Create a test submission for status tests.""" - submission = Submission( - entity_id=test_file.id, - evaluation_id=test_evaluation.id, - name=f"Test Submission {uuid.uuid4()}", - ) - created_submission = submission.store(synapse_client=syn) - schedule_for_cleanup(created_submission.id) - return created_submission - - def test_submission_cancellation_workflow(self, test_submission: Submission): - """Test the complete submission cancellation workflow.""" - # GIVEN a submission that exists - submission_id = test_submission.id - - # WHEN I get the initial submission status - initial_status = SubmissionStatus(id=submission_id).get(synapse_client=self.syn) - - # THEN initially it should not be cancellable or cancelled - assert initial_status.can_cancel is False - assert initial_status.cancel_requested is False - - # WHEN I update the submission status to allow cancellation - initial_status.can_cancel = True - updated_status = initial_status.store(synapse_client=self.syn) - - # THEN the submission should be marked as cancellable - assert updated_status.can_cancel is True - assert updated_status.cancel_requested is False - - # WHEN I cancel the submission - test_submission.cancel(synapse_client=self.syn) - - # THEN I should be able to retrieve the updated status showing cancellation was requested - final_status = SubmissionStatus(id=submission_id).get(synapse_client=self.syn) - assert final_status.can_cancel is True - assert final_status.cancel_requested is True - - -class TestSubmissionStatusValidation: - """Tests for SubmissionStatus validation and error handling.""" - - @pytest.fixture(autouse=True, scope="function") - def init(self, syn: Synapse, schedule_for_cleanup: Callable[..., None]) -> None: - self.syn = syn - self.schedule_for_cleanup = schedule_for_cleanup - - def test_to_synapse_request_missing_required_attributes(self): - """Test that to_synapse_request validates required attributes.""" - # WHEN I try to create a request with missing required attributes - submission_status = SubmissionStatus(id="123") # Missing etag, status_version - - # THEN it should raise a ValueError - with pytest.raises(ValueError, match="missing the 'etag' attribute"): - submission_status.to_synapse_request(synapse_client=self.syn) - - # WHEN I add etag but still missing status_version - submission_status.etag = "some-etag" - - # THEN it should raise a ValueError for status_version - with pytest.raises(ValueError, match="missing the 'status_version' attribute"): - submission_status.to_synapse_request(synapse_client=self.syn) - - def test_to_synapse_request_valid_attributes(self): - """Test that to_synapse_request works with valid attributes.""" - # WHEN I create a request with all required attributes - submission_status = SubmissionStatus( - id="123", - etag="some-etag", - status_version=1, - status="SCORED", - submission_annotations={"score": 85.5}, - ) - - # THEN it should create a valid request body - request_body = submission_status.to_synapse_request(synapse_client=self.syn) - - # AND the request should have the required fields - assert request_body["id"] == "123" - assert request_body["etag"] == "some-etag" - assert request_body["statusVersion"] == 1 - assert request_body["status"] == "SCORED" - assert "submissionAnnotations" in request_body - - def test_fill_from_dict_with_complete_response(self): - """Test filling a SubmissionStatus from a complete API response.""" - # GIVEN a complete API response - api_response = { - "id": "123456", - "etag": "abcd-1234", - "modifiedOn": "2023-01-01T00:00:00.000Z", - "status": "SCORED", - "entityId": "syn789", - "versionNumber": 1, - "statusVersion": 2, - "canCancel": False, - "cancelRequested": False, - "annotations": { - "objectId": "123456", - "scopeId": "9617645", - "stringAnnos": [ - { - "key": "internal_note", - "isPrivate": True, - "value": "This is internal", - }, - { - "key": "reviewer_notes", - "isPrivate": True, - "value": "Excellent work", - }, - ], - "doubleAnnos": [ - {"key": "validation_score", "isPrivate": True, "value": 95.0} - ], - "longAnnos": [], - }, - "submissionAnnotations": {"feedback": ["Great work!"], "score": [92.5]}, - } - - # WHEN I fill a SubmissionStatus from the response - submission_status = SubmissionStatus() - result = submission_status.fill_from_dict(api_response) - - # THEN all fields should be populated correctly - assert result.id == "123456" - assert result.etag == "abcd-1234" - assert result.modified_on == "2023-01-01T00:00:00.000Z" - assert result.status == "SCORED" - assert result.entity_id == "syn789" - assert result.version_number == 1 - assert result.status_version == 2 - assert result.can_cancel is False - assert result.cancel_requested is False - - # The annotations field should contain the raw submission status format - assert result.annotations is not None - assert "objectId" in result.annotations - assert "scopeId" in result.annotations - assert "stringAnnos" in result.annotations - assert "doubleAnnos" in result.annotations - assert len(result.annotations["stringAnnos"]) == 2 - assert len(result.annotations["doubleAnnos"]) == 1 - - # The submission_annotations should be in simple key-value format - assert "feedback" in result.submission_annotations - assert "score" in result.submission_annotations - assert result.submission_annotations["feedback"] == ["Great work!"] - assert result.submission_annotations["score"] == [92.5] - - def test_fill_from_dict_with_minimal_response(self): - """Test filling a SubmissionStatus from a minimal API response.""" - # GIVEN a minimal API response - api_response = {"id": "123456", "status": "RECEIVED"} - - # WHEN I fill a SubmissionStatus from the response - submission_status = SubmissionStatus() - result = submission_status.fill_from_dict(api_response) - - # THEN basic fields should be populated - assert result.id == "123456" - assert result.status == "RECEIVED" - # AND optional fields should have default values - assert result.etag is None - assert result.can_cancel is False - assert result.cancel_requested is False diff --git a/tests/integration/synapseclient/models/synchronous/test_submissionview.py b/tests/integration/synapseclient/models/synchronous/test_submissionview.py deleted file mode 100644 index b522541da..000000000 --- a/tests/integration/synapseclient/models/synchronous/test_submissionview.py +++ /dev/null @@ -1,689 +0,0 @@ -import tempfile -import time -import uuid -from typing import Callable - -import pandas as pd -import pytest - -from synapseclient import Evaluation, Synapse -from synapseclient.core.exceptions import SynapseHTTPError -from synapseclient.models import ( - Activity, - Column, - ColumnType, - File, - Project, - SubmissionView, - UsedURL, - query_part_mask, -) - - -class TestSubmissionViewCreation: - @pytest.fixture(autouse=True, scope="function") - def init(self, syn: Synapse, schedule_for_cleanup: Callable[..., None]) -> None: - self.syn = syn - self.schedule_for_cleanup = schedule_for_cleanup - - def test_create_submissionview_with_columns(self, project_model: Project) -> None: - # GIVEN a project to work with - # AND an evaluation to use in the scope - evaluation = self.syn.store( - Evaluation( - name=str(uuid.uuid4()), - description="Test evaluation for submission view", - contentSource=project_model.id, - ) - ) - self.schedule_for_cleanup(evaluation) - - # Test Case 1: Submissionview with default columns - # GIVEN a submissionview with default columns - submissionview_name = str(uuid.uuid4()) - submissionview_description = "Test submissionview" - submissionview = SubmissionView( - name=submissionview_name, - parent_id=project_model.id, - description=submissionview_description, - scope_ids=[evaluation.id], - ) - - # WHEN I store the submissionview - submissionview = submissionview.store(synapse_client=self.syn) - self.schedule_for_cleanup(submissionview) - - # THEN the submissionview should be created - assert submissionview.id is not None - - # AND I can retrieve that submissionview from Synapse - new_submissionview_instance = SubmissionView(id=submissionview.id).get( - synapse_client=self.syn - ) - assert new_submissionview_instance is not None - assert new_submissionview_instance.name == submissionview_name - assert new_submissionview_instance.id == submissionview.id - assert new_submissionview_instance.description == submissionview_description - - # AND the submissionview has columns - assert len(new_submissionview_instance.columns) > 0 - - # Test Case 2: Submissionview with a single custom column - # GIVEN a submissionview with a single column - submissionview_name2 = str(uuid.uuid4()) - submissionview_description2 = "Test submissionview with single column" - custom_column = "test_column" - submissionview2 = SubmissionView( - name=submissionview_name2, - parent_id=project_model.id, - description=submissionview_description2, - columns=[Column(name=custom_column, column_type=ColumnType.STRING)], - scope_ids=[evaluation.id], - ) - - # WHEN I store the submissionview - submissionview2 = submissionview2.store(synapse_client=self.syn) - self.schedule_for_cleanup(submissionview2) - - # THEN the submissionview should be created - assert submissionview2.id is not None - - # AND I can retrieve that submissionview from Synapse with the custom column - new_submissionview_instance2 = SubmissionView(id=submissionview2.id).get( - synapse_client=self.syn, include_columns=True - ) - assert new_submissionview_instance2 is not None - assert custom_column in new_submissionview_instance2.columns - assert new_submissionview_instance2.columns[custom_column].name == custom_column - assert ( - new_submissionview_instance2.columns[custom_column].column_type - == ColumnType.STRING - ) - - # Test Case 3: Submissionview with multiple custom columns - # GIVEN a submissionview with multiple columns - submissionview_name3 = str(uuid.uuid4()) - submissionview_description3 = "Test submissionview with multiple columns" - custom_column1 = "test_column1" - custom_column2 = "test_column2" - submissionview3 = SubmissionView( - name=submissionview_name3, - parent_id=project_model.id, - description=submissionview_description3, - columns=[ - Column(name=custom_column1, column_type=ColumnType.STRING), - Column(name=custom_column2, column_type=ColumnType.INTEGER), - ], - scope_ids=[evaluation.id], - ) - - # WHEN I store the submissionview - submissionview3 = submissionview3.store(synapse_client=self.syn) - self.schedule_for_cleanup(submissionview3) - - # THEN I can retrieve that submissionview with both columns - new_submissionview_instance3 = SubmissionView(id=submissionview3.id).get( - synapse_client=self.syn, include_columns=True - ) - assert custom_column1 in new_submissionview_instance3.columns - assert custom_column2 in new_submissionview_instance3.columns - assert ( - new_submissionview_instance3.columns[custom_column1].column_type - == ColumnType.STRING - ) - assert ( - new_submissionview_instance3.columns[custom_column2].column_type - == ColumnType.INTEGER - ) - - def test_create_submissionview_special_cases(self, project_model: Project) -> None: - # GIVEN a project to work with - # AND an evaluation to use in the scope - evaluation = self.syn.store( - Evaluation( - name=str(uuid.uuid4()), - description="Test evaluation for submission view", - contentSource=project_model.id, - ) - ) - self.schedule_for_cleanup(evaluation) - - # Test Case 1: Creating a submissionview with an invalid column - # GIVEN a submissionview with an invalid column - submissionview_name = str(uuid.uuid4()) - submissionview = SubmissionView( - name=submissionview_name, - parent_id=project_model.id, - description="Test submissionview with invalid column", - columns=[ - Column( - name="test_column", - column_type=ColumnType.STRING, - maximum_size=999999999, # Too large - ) - ], - scope_ids=[evaluation.id], - ) - - # WHEN I try to store the submissionview - # THEN it should fail with a specific error - with pytest.raises(SynapseHTTPError) as e: - submissionview.store(synapse_client=self.syn) - assert ( - "400 Client Error: ColumnModel.maxSize for a STRING cannot exceed:" - in str(e.value) - ) - - # Test Case 2: Creating a submissionview with empty scope - # GIVEN a submissionview with no scope - submissionview_name2 = str(uuid.uuid4()) - submissionview2 = SubmissionView( - name=submissionview_name2, - parent_id=project_model.id, - description="Test submissionview with empty scope", - scope_ids=[], - ) - - # WHEN I store the submissionview - submissionview2 = submissionview2.store(synapse_client=self.syn) - self.schedule_for_cleanup(submissionview2) - - # THEN the submissionview should be created but with empty scope - retrieved_view = SubmissionView(id=submissionview2.id).get( - synapse_client=self.syn - ) - assert len(retrieved_view.scope_ids) == 0 - - # Test Case 3: Creating a submissionview without default columns - # GIVEN a submissionview with custom columns but no default columns - submissionview_name3 = str(uuid.uuid4()) - custom_column1 = "custom_column1" - custom_column2 = "custom_column2" - submissionview3 = SubmissionView( - name=submissionview_name3, - parent_id=project_model.id, - description="Test submissionview without default columns", - include_default_columns=False, - columns=[ - Column(name=custom_column1, column_type=ColumnType.STRING), - Column(name=custom_column2, column_type=ColumnType.INTEGER), - ], - scope_ids=[evaluation.id], - ) - - # WHEN I store the submissionview - submissionview3 = submissionview3.store(synapse_client=self.syn) - self.schedule_for_cleanup(submissionview3) - - # THEN the submissionview should only contain our custom columns - retrieved_view3 = SubmissionView(id=submissionview3.id).get( - synapse_client=self.syn, include_columns=True - ) - assert len(retrieved_view3.columns) == 2 - assert custom_column1 in retrieved_view3.columns - assert custom_column2 in retrieved_view3.columns - assert "id" not in retrieved_view3.columns - assert "name" not in retrieved_view3.columns - assert "createdOn" not in retrieved_view3.columns - - -class TestColumnAndScopeModifications: - @pytest.fixture(autouse=True, scope="function") - def init(self, syn: Synapse, schedule_for_cleanup: Callable[..., None]) -> None: - self.syn = syn - self.schedule_for_cleanup = schedule_for_cleanup - - def test_column_modifications(self, project_model: Project) -> None: - # GIVEN a project to work with - # AND an evaluation to use in the scope - evaluation = self.syn.store( - Evaluation( - name=str(uuid.uuid4()), - description="Test evaluation for submission view", - contentSource=project_model.id, - ) - ) - self.schedule_for_cleanup(evaluation) - - # AND a submissionview in Synapse with two columns - submissionview_name = str(uuid.uuid4()) - old_column_name = "column_string" - column_to_keep = "column_to_keep" - submissionview = SubmissionView( - name=submissionview_name, - parent_id=project_model.id, - columns=[ - Column(name=old_column_name, column_type=ColumnType.STRING), - Column(name=column_to_keep, column_type=ColumnType.STRING), - ], - scope_ids=[evaluation.id], - ) - submissionview = submissionview.store(synapse_client=self.syn) - self.schedule_for_cleanup(submissionview) - - # Test Case 1: Rename column - # WHEN I rename the column - new_column_name = "new_column_string" - submissionview.columns[old_column_name].name = new_column_name - - # AND I store the submissionview - submissionview.store(synapse_client=self.syn) - - # THEN the column name should be updated on the existing submissionview instance - assert submissionview.columns[new_column_name] is not None - assert old_column_name not in submissionview.columns - - # AND the new column name should be reflected in the Synapse submissionview - updated_view = SubmissionView(id=submissionview.id).get(synapse_client=self.syn) - assert new_column_name in updated_view.columns - assert old_column_name not in updated_view.columns - - # Test Case 2: Delete column - # WHEN I delete the renamed column - submissionview.delete_column(name=new_column_name) - - # AND I store the submissionview - submissionview.store(synapse_client=self.syn) - - # THEN the column should be removed from the submissionview instance - assert new_column_name not in submissionview.columns - assert column_to_keep in submissionview.columns - - # AND the column should be removed from the Synapse submissionview - updated_view2 = SubmissionView(id=submissionview.id).get( - synapse_client=self.syn - ) - assert new_column_name not in updated_view2.columns - assert column_to_keep in updated_view2.columns - - def test_scope_modifications(self, project_model: Project) -> None: - # GIVEN a project to work with - # AND two evaluations for testing scope changes - evaluation1 = self.syn.store( - Evaluation( - name=str(uuid.uuid4()), - description="Test evaluation 1", - contentSource=project_model.id, - ) - ) - self.schedule_for_cleanup(evaluation1) - - evaluation2 = self.syn.store( - Evaluation( - name=str(uuid.uuid4()), - description="Test evaluation 2", - contentSource=project_model.id, - ) - ) - self.schedule_for_cleanup(evaluation2) - - # AND a submissionview with one evaluation in scope - submissionview_name = str(uuid.uuid4()) - submissionview = SubmissionView( - name=submissionview_name, - parent_id=project_model.id, - scope_ids=[evaluation1.id], - ) - submissionview = submissionview.store(synapse_client=self.syn) - self.schedule_for_cleanup(submissionview) - - # Test Case 1: Update scope to include multiple evaluations - # WHEN I update the scope to include both evaluations - submissionview.scope_ids = [evaluation1.id, evaluation2.id] - updated_submissionview = submissionview.store(synapse_client=self.syn) - - # THEN the submissionview should have both evaluations in its scope - assert len(updated_submissionview.scope_ids) == 2 - assert evaluation1.id in updated_submissionview.scope_ids - assert evaluation2.id in updated_submissionview.scope_ids - - # AND when I retrieve the submissionview from Synapse it should have both evaluations - retrieved_submissionview = SubmissionView(id=submissionview.id).get( - synapse_client=self.syn - ) - assert len(retrieved_submissionview.scope_ids) == 2 - assert evaluation1.id in retrieved_submissionview.scope_ids - assert evaluation2.id in retrieved_submissionview.scope_ids - - # Test Case 2: Clear scope completely - # WHEN I clear the scope - submissionview.scope_ids = [] - cleared_submissionview = submissionview.store(synapse_client=self.syn) - - # THEN the submissionview should have an empty scope - assert len(cleared_submissionview.scope_ids) == 0 - - # AND when I retrieve the submissionview from Synapse it should have empty scope - retrieved_submissionview2 = SubmissionView(id=submissionview.id).get( - synapse_client=self.syn - ) - assert len(retrieved_submissionview2.scope_ids) == 0 - - -class TestQuerying: - @pytest.fixture(autouse=True, scope="function") - def init(self, syn: Synapse, schedule_for_cleanup: Callable[..., None]) -> None: - self.syn = syn - self.schedule_for_cleanup = schedule_for_cleanup - - def test_query_submissionview(self, project_model: Project) -> None: - # GIVEN a project to work with - # AND an evaluation to use in the scope - evaluation = self.syn.store( - Evaluation( - name=str(uuid.uuid4()), - description="Test evaluation for submission view", - contentSource=project_model.id, - ) - ) - self.schedule_for_cleanup(evaluation) - - # AND a submissionview with the evaluation in scope - submissionview_name = str(uuid.uuid4()) - submissionview = SubmissionView( - name=submissionview_name, - parent_id=project_model.id, - scope_ids=[evaluation.id], - ) - submissionview = submissionview.store(synapse_client=self.syn) - self.schedule_for_cleanup(submissionview) - - # Test Case 1: Simple query - # WHEN I query the submissionview with a standard query - results = submissionview.query( - f"SELECT * FROM {submissionview.id}", - synapse_client=self.syn, - ) - - # THEN results should be returned (even if empty) - assert results is not None - assert isinstance(results, pd.DataFrame) - - # Test Case 2: Query with part mask - # WHEN I query the submissionview with a part mask - query_results = 0x1 - query_count = 0x2 - last_updated_on = 0x80 - part_mask = query_results | query_count | last_updated_on - - mask_results = query_part_mask( - query=f"SELECT * FROM {submissionview.id}", - synapse_client=self.syn, - part_mask=part_mask, - ) - - # THEN the part mask should be reflected in the results - assert mask_results is not None - assert mask_results.result is not None - assert mask_results.count is not None - assert mask_results.last_updated_on is not None - - -class TestSnapshotting: - @pytest.fixture(autouse=True, scope="function") - def init(self, syn: Synapse, schedule_for_cleanup: Callable[..., None]) -> None: - self.syn = syn - self.schedule_for_cleanup = schedule_for_cleanup - - def test_submissionview_snapshots(self, project_model: Project) -> None: - # GIVEN a project to work with - # AND an evaluation to use in the scope - evaluation = self.syn.store( - Evaluation( - name=str(uuid.uuid4()), - description="Test evaluation for submission view", - contentSource=project_model.id, - ) - ) - self.schedule_for_cleanup(evaluation) - - # Test Case 1: Snapshot with Activity - # GIVEN a submissionview with activity - submissionview_name = str(uuid.uuid4()) - submissionview_description = "Test submissionview" - submissionview = SubmissionView( - name=submissionview_name, - parent_id=project_model.id, - description=submissionview_description, - scope_ids=[evaluation.id], - activity=Activity( - name="Activity for snapshot", - used=[UsedURL(name="Synapse", url="https://synapse.org")], - ), - ) - - # AND the submissionview is stored in Synapse - submissionview = submissionview.store(synapse_client=self.syn) - self.schedule_for_cleanup(submissionview) - - # WHEN I snapshot the submissionview - snapshot = submissionview.snapshot( - comment="My snapshot", - label="My snapshot label", - include_activity=True, - associate_activity_to_new_version=True, - synapse_client=self.syn, - ) - - # THEN the view should be snapshotted - assert snapshot.results is not None - - # AND getting the first version should return the snapshot instance - snapshot_instance = SubmissionView(id=submissionview.id, version_number=1).get( - synapse_client=self.syn, include_activity=True - ) - assert snapshot_instance is not None - assert snapshot_instance.version_number == 1 - assert snapshot_instance.id == submissionview.id - assert snapshot_instance.version_comment == "My snapshot" - assert snapshot_instance.version_label == "My snapshot label" - assert snapshot_instance.activity.name == "Activity for snapshot" - assert snapshot_instance.activity.used[0].name == "Synapse" - - # AND The activity should be associated with the new version - newest_instance = SubmissionView(id=submissionview.id).get( - synapse_client=self.syn, include_activity=True - ) - assert newest_instance.version_number == 2 - assert newest_instance.activity is not None - assert newest_instance.activity.name == "Activity for snapshot" - - # Test Case 2: Snapshot with no scope - # GIVEN a submissionview with no scope - empty_view_name = str(uuid.uuid4()) - empty_view = SubmissionView( - name=empty_view_name, - parent_id=project_model.id, - description="Test submissionview with no scope", - ) - - # AND the submissionview is stored in Synapse - empty_view = empty_view.store(synapse_client=self.syn) - self.schedule_for_cleanup(empty_view.id) - - # WHEN I try to snapshot the submissionview - # THEN it should fail with a specific error - with pytest.raises(SynapseHTTPError) as e: - empty_view.snapshot( - comment="My snapshot", - label="My snapshot label", - synapse_client=self.syn, - ) - assert ( - "400 Client Error: You cannot create a version of a view that has no scope." - in str(e.value) - ) - - -class TestSubmissionViewWithSubmissions: - @pytest.fixture(autouse=True, scope="function") - def init(self, syn: Synapse, schedule_for_cleanup: Callable[..., None]) -> None: - self.syn = syn - self.schedule_for_cleanup = schedule_for_cleanup - - def test_submission_lifecycle(self, project_model: Project) -> None: - """Test submission lifecycle in a submission view: adding and removing submissions.""" - # GIVEN an evaluation - evaluation = self.syn.store( - Evaluation( - name=str(uuid.uuid4()), - description="Test evaluation for submission view", - contentSource=project_model.id, - ) - ) - self.schedule_for_cleanup(evaluation) - - # AND a submissionview that includes the evaluation - submissionview = SubmissionView( - name=str(uuid.uuid4()), - parent_id=project_model.id, - description="Test submissionview for submissions", - scope_ids=[evaluation.id], - ) - submissionview = submissionview.store(synapse_client=self.syn) - self.schedule_for_cleanup(submissionview) - - # AND a file for submission - with tempfile.NamedTemporaryFile(mode="w", suffix=".txt", delete=False) as f: - filename = f.name - f.write("Test content for submission") - self.schedule_for_cleanup(filename) - - file_entity = File(path=filename, parent_id=project_model.id, name="Test file") - file_entity = file_entity.store(synapse_client=self.syn) - self.schedule_for_cleanup(file_entity.id) - - # WHEN I submit the file to the evaluation - submission = self.syn.submit( - evaluation, - file_entity.id, - name="Test submission", - submitterAlias="Test submitter", - ) - - # THEN eventually the submission should appear in the submission view - max_attempts = 10 - wait_seconds = 3 - success = False - - for attempt in range(max_attempts): - results = submissionview.query( - f"SELECT * FROM {submissionview.id}", - synapse_client=self.syn, - ) - - if len(results) == 1: - success = True - break - - time.sleep(wait_seconds) - wait_seconds *= 1.5 - - assert success, "Submission did not appear in the view" - assert len(results) == 1 - assert results["name"].iloc[0] == "Test submission" - - # WHEN I delete the submission - self.syn.restDELETE(f"/evaluation/submission/{submission.id}") - - # THEN eventually the submission should be removed from the view - wait_seconds = 3 - success = False - - for attempt in range(max_attempts): - results = submissionview.query( - f"SELECT * FROM {submissionview.id}", - synapse_client=self.syn, - ) - - if len(results) == 0: - success = True - break - - time.sleep(wait_seconds) - wait_seconds *= 1.5 - - assert success, "Deleted submission still appears in the view" - - def test_multiple_submissions(self, project_model: Project) -> None: - """Test that multiple submissions to an evaluation appear in a submission view.""" - # GIVEN an evaluation - evaluation = self.syn.store( - Evaluation( - name=str(uuid.uuid4()), - description="Test evaluation for multiple submissions", - contentSource=project_model.id, - ) - ) - self.schedule_for_cleanup(evaluation) - - # AND a submissionview that includes the evaluation - submissionview = SubmissionView( - name=str(uuid.uuid4()), - parent_id=project_model.id, - description="Test submissionview for multiple submissions", - scope_ids=[evaluation.id], - ) - submissionview = submissionview.store(synapse_client=self.syn) - self.schedule_for_cleanup(submissionview) - - # WHEN I create and upload multiple test files for submission - files = [] - submissions = [] - - for i in range(3): - # Create test file - with tempfile.NamedTemporaryFile( - mode="w", suffix=".txt", delete=False - ) as f: - filename = f.name - f.write(f"Test content for submission {i}") - self.schedule_for_cleanup(filename) - - # Store file in Synapse - file_entity = File( - path=filename, parent_id=project_model.id, name=f"Test file {i}" - ) - file_entity = file_entity.store(synapse_client=self.syn) - self.schedule_for_cleanup(file_entity.id) - files.append(file_entity) - - # Submit to evaluation - submission = self.syn.submit( - evaluation, - file_entity.id, - name=f"Submission {i}", - submitterAlias=f"Test submitter {i}", - ) - submissions.append(submission) - - # THEN eventually all submissions should appear in the submission view - max_attempts = 10 - wait_seconds = 3 - success = False - - for attempt in range(max_attempts): - results = submissionview.query( - f"SELECT * FROM {submissionview.id}", - synapse_client=self.syn, - ) - - if len(results) == len(submissions): - success = True - break - - time.sleep(wait_seconds) - wait_seconds *= 1.5 - - assert ( - success - ), f"Submissions did not appear in the view after {max_attempts} attempts" - assert len(results) == len(submissions) - - # Verify each submission is present - for i in range(len(submissions)): - submission_name = f"Submission {i}" - matching_rows = results[results["name"] == submission_name] - assert ( - len(matching_rows) > 0 - ), f"Expected submission {submission_name} not found" diff --git a/tests/integration/synapseclient/models/synchronous/test_sync_wrapper_smoke.py b/tests/integration/synapseclient/models/synchronous/test_sync_wrapper_smoke.py new file mode 100644 index 000000000..22b2ae305 --- /dev/null +++ b/tests/integration/synapseclient/models/synchronous/test_sync_wrapper_smoke.py @@ -0,0 +1,199 @@ +"""Smoke tests for synchronous (non-async) wrapper methods. + +These tests verify that the @async_to_sync decorator-generated sync methods +work correctly against the real Synapse API. They cover representative +operations across the most commonly used model classes. + +The full async integration test suite tests all business logic. These tests +only verify that the sync-to-async wrapping works end-to-end. +""" + +import sys +import uuid +from typing import Callable + +import pytest + +from synapseclient import Synapse +from synapseclient.core import utils +from synapseclient.core.exceptions import SynapseHTTPError +from synapseclient.models import ( + Column, + ColumnType, + File, + Folder, + Project, + Table, + WikiPage, +) + +pytestmark = pytest.mark.skipif( + sys.version_info >= (3, 14), + reason=( + "Sync wrappers raise RuntimeError on Python 3.14+ when an event loop " + "is active (which pytest-asyncio creates). Use async methods directly." + ), +) + + +class TestSyncWrapperSmoke: + """Smoke tests for sync wrapper methods across core model classes.""" + + @pytest.fixture(autouse=True, scope="function") + def init(self, syn: Synapse, schedule_for_cleanup: Callable[..., None]) -> None: + self.syn = syn + self.schedule_for_cleanup = schedule_for_cleanup + + def test_project_store_get_delete(self) -> None: + """Verify Project store/get/delete sync wrappers work.""" + # GIVEN a project + project = Project( + name=f"sync_smoke_project_{uuid.uuid4()}", + description="Sync wrapper smoke test", + ) + + # WHEN I store the project using the sync method + project = project.store(synapse_client=self.syn) + self.schedule_for_cleanup(project.id) + + # THEN the project should be stored + assert project.id is not None + assert project.etag is not None + + # WHEN I get the project using the sync method + retrieved = Project(id=project.id).get(synapse_client=self.syn) + + # THEN the project should be retrieved + assert retrieved.id == project.id + assert retrieved.name == project.name + + # WHEN I delete the project using the sync method + project.delete(synapse_client=self.syn) + + # THEN the project should be deleted + with pytest.raises(SynapseHTTPError, match="404"): + Project(id=project.id).get(synapse_client=self.syn) + + def test_file_store_and_get(self, project_model: Project) -> None: + """Verify File store/get sync wrappers work.""" + # GIVEN a file + filename = utils.make_bogus_uuid_file() + self.schedule_for_cleanup(filename) + file = File( + path=filename, + name=f"sync_smoke_file_{uuid.uuid4()}.txt", + description="Sync wrapper smoke test", + parent_id=project_model.id, + ) + + # WHEN I store the file using the sync method + file = file.store(synapse_client=self.syn) + self.schedule_for_cleanup(file.id) + + # THEN the file should be stored + assert file.id is not None + assert file.version_number == 1 + assert file.data_file_handle_id is not None + + # WHEN I get the file using the sync method + retrieved = File(id=file.id, download_file=False).get(synapse_client=self.syn) + + # THEN the file should be retrieved + assert retrieved.id == file.id + assert retrieved.name == file.name + + def test_folder_store_and_get(self, project_model: Project) -> None: + """Verify Folder store/get sync wrappers work.""" + # GIVEN a folder + folder = Folder( + name=f"sync_smoke_folder_{uuid.uuid4()}", + parent_id=project_model.id, + ) + + # WHEN I store the folder using the sync method + folder = folder.store(synapse_client=self.syn) + self.schedule_for_cleanup(folder.id) + + # THEN the folder should be stored + assert folder.id is not None + + # WHEN I get the folder using the sync method + retrieved = Folder(id=folder.id).get(synapse_client=self.syn) + + # THEN the folder should be retrieved + assert retrieved.id == folder.id + assert retrieved.name == folder.name + + def test_table_store_and_query(self, project_model: Project) -> None: + """Verify Table store and query sync wrappers work.""" + # GIVEN a table with columns + table = Table( + name=f"sync_smoke_table_{uuid.uuid4()}", + parent_id=project_model.id, + columns=[ + Column(name="name", column_type=ColumnType.STRING, maximum_size=50), + Column(name="value", column_type=ColumnType.INTEGER), + ], + ) + + # WHEN I store the table using the sync method + table = table.store(synapse_client=self.syn) + self.schedule_for_cleanup(table.id) + + # THEN the table should be stored + assert table.id is not None + assert len(table.columns) == 2 + + def test_wiki_store_and_get(self, project_model: Project) -> None: + """Verify WikiPage store/get sync wrappers work.""" + # GIVEN a wiki page + wiki = WikiPage( + owner_id=project_model.id, + title=f"sync_smoke_wiki_{uuid.uuid4()}", + markdown="# Smoke Test\nThis is a sync wrapper test.", + ) + + # WHEN I store the wiki using the sync method + wiki = wiki.store(synapse_client=self.syn) + self.schedule_for_cleanup(wiki) + + # THEN the wiki should be stored + assert wiki.id is not None + assert wiki.etag is not None + + # WHEN I get the wiki using the sync method + retrieved = WikiPage(owner_id=project_model.id, id=wiki.id).get( + synapse_client=self.syn + ) + + # THEN the wiki should be retrieved + assert retrieved.id == wiki.id + assert retrieved.title == wiki.title + + def test_project_with_annotations(self) -> None: + """Verify annotation handling through sync wrappers.""" + # GIVEN a project with annotations + annotations = { + "my_string": ["hello"], + "my_int": [42], + "my_float": [3.14], + } + project = Project( + name=f"sync_smoke_annotations_{uuid.uuid4()}", + annotations=annotations, + ) + + # WHEN I store the project using the sync method + project = project.store(synapse_client=self.syn) + self.schedule_for_cleanup(project.id) + + # THEN the annotations should be stored + assert project.annotations["my_string"] == ["hello"] + assert project.annotations["my_int"] == [42] + assert project.annotations["my_float"] == [3.14] + + # WHEN I get the project using the sync method + retrieved = Project(id=project.id).get(synapse_client=self.syn) + + # THEN the annotations should be retrieved + assert retrieved.annotations == project.annotations diff --git a/tests/integration/synapseclient/models/synchronous/test_table.py b/tests/integration/synapseclient/models/synchronous/test_table.py deleted file mode 100644 index d0629b75d..000000000 --- a/tests/integration/synapseclient/models/synchronous/test_table.py +++ /dev/null @@ -1,2416 +0,0 @@ -import json -import os -import random -import string -import tempfile -import uuid -from typing import Callable -from unittest import skip - -import pandas as pd -import pytest -from pytest_mock import MockerFixture - -import synapseclient.models.mixins.asynchronous_job as asynchronous_job_module -import synapseclient.models.mixins.table_components as table_module -from synapseclient import Evaluation, Synapse -from synapseclient.core import utils -from synapseclient.core.constants import concrete_types -from synapseclient.core.exceptions import SynapseHTTPError -from synapseclient.models import ( - Activity, - Column, - ColumnExpansionStrategy, - ColumnType, - File, - Project, - SchemaStorageStrategy, - Table, - query, - query_part_mask, -) - - -class TestTableCreation: - @pytest.fixture(autouse=True, scope="function") - def init(self, syn: Synapse, schedule_for_cleanup: Callable[..., None]) -> None: - self.syn = syn - self.schedule_for_cleanup = schedule_for_cleanup - - def test_create_table_with_different_column_configurations( - self, project_model: Project - ) -> None: - """Test creating tables with different column configurations.""" - # Test 1: Table with no columns - # GIVEN a table with no columns - table_name = str(uuid.uuid4()) - table_description = "Test table with no columns" - table = Table( - name=table_name, parent_id=project_model.id, description=table_description - ) - - # WHEN I store the table - table = table.store(synapse_client=self.syn) - self.schedule_for_cleanup(table.id) - - # THEN the table should be created - assert table.id is not None - - # AND I can retrieve that table from Synapse - new_table_instance = Table(id=table.id).get(synapse_client=self.syn) - assert new_table_instance is not None - assert new_table_instance.name == table_name - assert new_table_instance.id == table.id - assert new_table_instance.description == table_description - - # Test 2: Table with a single column - # GIVEN a table with a single column - table_name = str(uuid.uuid4()) - table_description = "Test table with single column" - table_single_column = Table( - name=table_name, - parent_id=project_model.id, - description=table_description, - columns=[Column(name="test_column", column_type=ColumnType.STRING)], - ) - - # WHEN I store the table - table_single_column = table_single_column.store(synapse_client=self.syn) - self.schedule_for_cleanup(table_single_column.id) - - # THEN the table should be created - assert table_single_column.id is not None - - # AND I can retrieve that table from Synapse - new_table_instance = Table(id=table_single_column.id).get( - synapse_client=self.syn, include_columns=True - ) - assert new_table_instance.name == table_name - assert new_table_instance.columns["test_column"].name == "test_column" - assert ( - new_table_instance.columns["test_column"].column_type == ColumnType.STRING - ) - - # Test 3: Table with multiple columns - # GIVEN a table with multiple columns - table_name = str(uuid.uuid4()) - table_multi_columns = Table( - name=table_name, - parent_id=project_model.id, - description="Test table with multiple columns", - columns=[ - Column(name="test_column", column_type=ColumnType.STRING), - Column(name="test_column2", column_type=ColumnType.INTEGER), - ], - ) - - # WHEN I store the table - table_multi_columns = table_multi_columns.store(synapse_client=self.syn) - self.schedule_for_cleanup(table_multi_columns.id) - - # THEN the table should be created and columns are correct - new_table_instance = Table(id=table_multi_columns.id).get( - synapse_client=self.syn, include_columns=True - ) - assert ( - new_table_instance.columns["test_column"].column_type == ColumnType.STRING - ) - assert ( - new_table_instance.columns["test_column2"].column_type == ColumnType.INTEGER - ) - - def test_create_table_with_many_column_types(self, project_model: Project) -> None: - """Test creating a table with many column types with different allowed characters.""" - # GIVEN a table with many columns with various naming patterns - table_name = str(uuid.uuid4()) - table = Table( - name=table_name, - parent_id=project_model.id, - description="Test table with various column names", - columns=[ - Column(name="col1", column_type=ColumnType.STRING, id="id1"), - Column(name="col 2", column_type=ColumnType.STRING, id="id2"), - Column(name="col_3", column_type=ColumnType.STRING, id="id3"), - Column(name="col-4", column_type=ColumnType.STRING, id="id4"), - Column(name="col.5", column_type=ColumnType.STRING, id="id5"), - Column(name="col+6", column_type=ColumnType.STRING, id="id6"), - Column(name="col'7", column_type=ColumnType.STRING, id="id7"), - Column(name="col(8)", column_type=ColumnType.STRING, id="id8"), - ], - ) - - # WHEN I store the table - table = table.store(synapse_client=self.syn) - self.schedule_for_cleanup(table.id) - - # THEN the table should be created with all columns - new_table_instance = Table(id=table.id).get( - synapse_client=self.syn, include_columns=True - ) - - # Verify all column names and types - column_names = [ - "col1", - "col 2", - "col_3", - "col-4", - "col.5", - "col+6", - "col'7", - "col(8)", - ] - for name in column_names: - assert name in new_table_instance.columns - assert new_table_instance.columns[name].column_type == ColumnType.STRING - - def test_create_table_with_invalid_column(self, project_model: Project) -> None: - """Test creating a table with an invalid column configuration.""" - # GIVEN a table with an invalid column (maximum_size too large) - table_name = str(uuid.uuid4()) - table = Table( - name=table_name, - parent_id=project_model.id, - description="Test table with invalid column", - columns=[ - Column( - name="test_column", - column_type=ColumnType.STRING, - maximum_size=999999999, - ) - ], - ) - - # WHEN I store the table, THEN it should fail with appropriate error - with pytest.raises(SynapseHTTPError) as e: - table.store(synapse_client=self.syn) - - # Verify error message - assert ( - "400 Client Error: ColumnModel.maxSize for a STRING cannot exceed:" - in str(e.value) - ) - - def test_table_creation_with_data_sources(self, project_model: Project) -> None: - """Test creating tables with different data sources.""" - # Test with dictionary data - # GIVEN a table with no columns defined and dictionary data - table_name = str(uuid.uuid4()) - table_dict = Table(name=table_name, parent_id=project_model.id) - dict_data = { - "column_string": ["value1", "value2", "value3"], - } - - # WHEN I store the table and then add data - table_dict = table_dict.store(synapse_client=self.syn) - self.schedule_for_cleanup(table_dict.id) - table_dict.store_rows( - values=dict_data, - schema_storage_strategy=SchemaStorageStrategy.INFER_FROM_DATA, - synapse_client=self.syn, - ) - - # THEN the table should have proper schema and data - results = query(f"SELECT * FROM {table_dict.id}", synapse_client=self.syn) - pd.testing.assert_series_equal( - results["column_string"], - pd.DataFrame(dict_data)["column_string"], - check_dtype=False, - ) - - # Test with DataFrame data - # GIVEN a table with no columns defined and pandas DataFrame data - table_name = str(uuid.uuid4()) - table_df = Table(name=table_name, parent_id=project_model.id) - df_data = pd.DataFrame({"column_string": ["value1", "value2", "value3"]}) - - # WHEN I store the table and then add data - table_df = table_df.store(synapse_client=self.syn) - self.schedule_for_cleanup(table_df.id) - table_df.store_rows( - values=df_data, - schema_storage_strategy=SchemaStorageStrategy.INFER_FROM_DATA, - synapse_client=self.syn, - ) - - # THEN the table should have proper schema and data - results = query(f"SELECT * FROM {table_df.id}", synapse_client=self.syn) - pd.testing.assert_series_equal( - results["column_string"], df_data["column_string"], check_dtype=False - ) - - # Test with CSV file data - # GIVEN a table with no columns defined and CSV file data - table_name = str(uuid.uuid4()) - table_csv = Table(name=table_name, parent_id=project_model.id) - csv_data = pd.DataFrame({"column_string": ["value1", "value2", "value3"]}) - filepath = f"{tempfile.mkdtemp()}/upload_{uuid.uuid4()}.csv" - self.schedule_for_cleanup(filepath) - csv_data.to_csv(filepath, index=False, float_format="%.12g") - - # WHEN I store the table and add data from CSV - table_csv = table_csv.store(synapse_client=self.syn) - self.schedule_for_cleanup(table_csv.id) - table_csv.store_rows( - values=filepath, - schema_storage_strategy=SchemaStorageStrategy.INFER_FROM_DATA, - synapse_client=self.syn, - ) - - # THEN the table should have proper schema and data - results = query(f"SELECT * FROM {table_csv.id}", synapse_client=self.syn) - pd.testing.assert_series_equal( - results["column_string"], csv_data["column_string"], check_dtype=False - ) - - def test_create_table_with_string_column(self, project_model: Project) -> None: - """Test creating tables with string column configurations.""" - # GIVEN a table with columns - table_name = str(uuid.uuid4()) - table_description = "Test table with columns" - table = Table( - name=table_name, - parent_id=project_model.id, - description=table_description, - columns=[ - Column(name="test_column", column_type="STRING"), - Column(name="test_column2", column_type="INTEGER"), - ], - ) - - # WHEN I store the table - table = table.store(synapse_client=self.syn) - self.schedule_for_cleanup(table.id) - - # THEN the table should be created - assert table.id is not None - - # AND I can retrieve that table from Synapse - new_table_instance = Table(id=table.id).get(synapse_client=self.syn) - assert new_table_instance is not None - assert new_table_instance.name == table_name - assert new_table_instance.id == table.id - assert new_table_instance.description == table_description - assert len(new_table_instance.columns) == 2 - assert new_table_instance.columns["test_column"].name == "test_column" - assert ( - new_table_instance.columns["test_column"].column_type == ColumnType.STRING - ) - assert new_table_instance.columns["test_column2"].name == "test_column2" - assert ( - new_table_instance.columns["test_column2"].column_type == ColumnType.INTEGER - ) - - -class TestRowStorage: - @pytest.fixture(autouse=True, scope="function") - def init(self, syn: Synapse, schedule_for_cleanup: Callable[..., None]) -> None: - self.syn = syn - self.schedule_for_cleanup = schedule_for_cleanup - - def test_store_rows_from_csv_infer_columns( - self, mocker: MockerFixture, project_model: Project - ) -> None: - # SPYs - spy_csv_file_conversion = mocker.spy(table_module, "csv_to_pandas_df") - - # GIVEN a table with no columns defined - table_name = str(uuid.uuid4()) - table = Table(name=table_name, parent_id=project_model.id) - - # AND data for a column stored to CSV - data_for_table = pd.DataFrame( - { - "column_string": ["value1", "value2", "value3", "value4"], - "integer_string": [1, 2, 3, None], - "float_string": [1.1, 2.2, 3.3, None], - } - ) - filepath = f"{tempfile.mkdtemp()}/upload_{uuid.uuid4()}.csv" - self.schedule_for_cleanup(filepath) - data_for_table.to_csv(filepath, index=False, float_format="%.12g") - - # WHEN I store the table - table = table.store(synapse_client=self.syn) - self.schedule_for_cleanup(table.id) - - # AND I store rows to the table with INFER_FROM_DATA schema storage strategy - table.store_rows( - values=filepath, - schema_storage_strategy=SchemaStorageStrategy.INFER_FROM_DATA, - synapse_client=self.syn, - ) - - # THEN the table should be created - assert table.id is not None - - # AND the spy should have been called - spy_csv_file_conversion.assert_called_once() - - # AND I can query the table - results = query(f"SELECT * FROM {table.id}", synapse_client=self.syn) - - # AND the data in the columns should match - pd.testing.assert_series_equal( - results["column_string"], data_for_table["column_string"], check_dtype=False - ) - pd.testing.assert_series_equal( - results["integer_string"], - data_for_table["integer_string"], - check_dtype=False, - ) - pd.testing.assert_series_equal( - results["float_string"], data_for_table["float_string"], check_dtype=False - ) - - def test_update_rows_from_csv_infer_columns_no_column_updates( - self, project_model: Project - ) -> None: - # GIVEN a table with no columns defined - table_name = str(uuid.uuid4()) - table = Table(name=table_name, parent_id=project_model.id) - - # AND data for a column stored to CSV - data_for_table = pd.DataFrame( - { - "column_string": ["value1", "value2", "value3"], - } - ) - filepath = f"{tempfile.mkdtemp()}/upload_{uuid.uuid4()}.csv" - self.schedule_for_cleanup(filepath) - data_for_table.to_csv(filepath, index=False, float_format="%.12g") - - # AND the table is stored in Synapse - table = table.store(synapse_client=self.syn) - self.schedule_for_cleanup(table.id) - - # AND Rows are stored into Synapse with the INFER_FROM_DATA schema storage strategy - table.store_rows( - values=filepath, - schema_storage_strategy=SchemaStorageStrategy.INFER_FROM_DATA, - synapse_client=self.syn, - ) - assert table.id is not None - - # AND a query of the data - query_results = query(f"SELECT * FROM {table.id}", synapse_client=self.syn) - - # WHEN I update the rows with new data - query_results.loc[ - query_results["column_string"] == "value1", "column_string" - ] = "value11" - query_results.loc[ - query_results["column_string"] == "value3", "column_string" - ] = "value33" - - # AND I store the rows back to Synapse - Table(id=table.id).store_rows( - values=query_results, - schema_storage_strategy=SchemaStorageStrategy.INFER_FROM_DATA, - synapse_client=self.syn, - ) - - # THEN the data should be stored in Synapse, and match the updated data - updated_results_from_table = query( - f"SELECT * FROM {table.id}", synapse_client=self.syn - ) - pd.testing.assert_series_equal( - updated_results_from_table["column_string"], query_results["column_string"] - ) - - def test_store_rows_from_csv_no_columns(self, project_model: Project) -> None: - # GIVEN a table with no columns defined - table_name = str(uuid.uuid4()) - table = Table(name=table_name, parent_id=project_model.id) - - # AND data for a column stored to CSV - data_for_table = pd.DataFrame( - { - "column_string": ["value1", "value2", "value3"], - } - ) - filepath = f"{tempfile.mkdtemp()}/upload_{uuid.uuid4()}.csv" - self.schedule_for_cleanup(filepath) - data_for_table.to_csv(filepath, index=False, float_format="%.12g") - - # WHEN I store the table - table = table.store(synapse_client=self.syn) - self.schedule_for_cleanup(table.id) - - # WHEN I store rows to the table with no schema storage strategy - with pytest.raises(SynapseHTTPError) as e: - table.store_rows( - values=filepath, schema_storage_strategy=None, synapse_client=self.syn - ) - - # THEN the table data should fail to be inserted - assert ( - "400 Client Error: The first line is expected to be a header but the values do not match the names of of the columns of the table (column_string is not a valid column name or id). Header row: column_string" - in str(e.value) - ) - - def test_store_rows_from_manually_defined_columns( - self, mocker: MockerFixture, project_model: Project - ) -> None: - # SPYs - spy_csv_file_conversion = mocker.spy(table_module, "csv_to_pandas_df") - - # GIVEN a table with a column defined - table_name = str(uuid.uuid4()) - table = Table( - name=table_name, - parent_id=project_model.id, - columns=[ - Column(name="column_string", column_type=ColumnType.STRING), - Column(name="integer_column", column_type=ColumnType.INTEGER), - Column(name="float_column", column_type=ColumnType.DOUBLE), - ], - ) - - # AND data for a column stored to CSV - data_for_table = pd.DataFrame( - { - "column_string": ["value1", "value2", "value3", "value4"], - "integer_column": [1, 2, 3, None], - "float_column": [1.1, 2.2, 3.3, None], - } - ) - filepath = f"{tempfile.mkdtemp()}/upload_{uuid.uuid4()}.csv" - self.schedule_for_cleanup(filepath) - data_for_table.to_csv(filepath, index=False, float_format="%.12g") - - # WHEN I store the table - table = table.store(synapse_client=self.syn) - self.schedule_for_cleanup(table.id) - - # AND I store rows to the table with no schema storage strategy - table.store_rows( - values=filepath, schema_storage_strategy=None, synapse_client=self.syn - ) - - # THEN the table should be created - assert table.id is not None - - # AND the spy should not have been called - spy_csv_file_conversion.assert_not_called() - - # AND I can query the table - results = query(f"SELECT * FROM {table.id}", synapse_client=self.syn) - - # AND the data in the columns should match - pd.testing.assert_series_equal( - results["column_string"], data_for_table["column_string"], check_dtype=False - ) - pd.testing.assert_series_equal( - results["integer_column"], - data_for_table["integer_column"], - check_dtype=False, - ) - pd.testing.assert_series_equal( - results["float_column"], data_for_table["float_column"], check_dtype=False - ) - - def test_store_rows_on_existing_table_with_schema_storage_strategy( - self, mocker: MockerFixture, project_model: Project - ) -> None: - # SPYs - spy_csv_file_conversion = mocker.spy(table_module, "csv_to_pandas_df") - - # GIVEN a table with a column defined - table_name = str(uuid.uuid4()) - table = Table( - name=table_name, - parent_id=project_model.id, - columns=[Column(name="column_string", column_type=ColumnType.STRING)], - ) - - # AND the table exists in Synapse - table = table.store(synapse_client=self.syn) - self.schedule_for_cleanup(table.id) - spy_send_job = mocker.spy(asynchronous_job_module, "send_job_async") - - # AND data for a column stored to CSV - data_for_table = pd.DataFrame( - { - "column_string": ["value1", "value2", "value3"], - } - ) - filepath = f"{tempfile.mkdtemp()}/upload_{uuid.uuid4()}.csv" - self.schedule_for_cleanup(filepath) - data_for_table.to_csv(filepath, index=False, float_format="%.12g") - - # WHEN I store rows to the table with a schema storage strategy - table.store_rows( - values=filepath, - schema_storage_strategy=SchemaStorageStrategy.INFER_FROM_DATA, - synapse_client=self.syn, - ) - - # THEN the spy should have been called - spy_csv_file_conversion.assert_called_once() - - # AND the schema should not have been updated - assert len(spy_send_job.call_args.kwargs["request"]["changes"]) == 1 - assert ( - spy_send_job.call_args.kwargs["request"]["changes"][0]["concreteType"] - == concrete_types.UPLOAD_TO_TABLE_REQUEST - ) - - # AND I can query the table - results = query(f"SELECT * FROM {table.id}", synapse_client=self.syn) - - # AND the data in the columns should match - pd.testing.assert_series_equal( - results["column_string"], data_for_table["column_string"], check_dtype=False - ) - - def test_store_rows_on_existing_table_with_expanding_string_column( - self, mocker: MockerFixture, project_model: Project - ) -> None: - # SPYs - spy_csv_file_conversion = mocker.spy(table_module, "csv_to_pandas_df") - - # GIVEN a table with a column defined - table_name = str(uuid.uuid4()) - table = Table( - name=table_name, - parent_id=project_model.id, - columns=[ - Column( - name="column_string", column_type=ColumnType.STRING, maximum_size=10 - ) - ], - ) - - # AND the table exists in Synapse - table = table.store(synapse_client=self.syn) - self.schedule_for_cleanup(table.id) - spy_send_job = mocker.spy(asynchronous_job_module, "send_job_async") - - # AND data for a column stored to CSV - data_for_table = pd.DataFrame( - { - "column_string": [ - "long_string_value_over_maximum_size1", - "long_string_value_over_maximum_size2", - "long_string_value_over_maximum_size3", - ], - } - ) - filepath = f"{tempfile.mkdtemp()}/upload_{uuid.uuid4()}.csv" - self.schedule_for_cleanup(filepath) - data_for_table.to_csv(filepath, index=False, float_format="%.12g") - - # WHEN I store rows to the table with a schema storage strategy - table.store_rows( - values=filepath, - schema_storage_strategy=SchemaStorageStrategy.INFER_FROM_DATA, - column_expansion_strategy=ColumnExpansionStrategy.AUTO_EXPAND_CONTENT_LENGTH, - synapse_client=self.syn, - ) - - # THEN the spy should have been called - spy_csv_file_conversion.assert_called_once() - - # AND the schema should have been updated before the data is stored - assert len(spy_send_job.call_args.kwargs["request"]["changes"]) == 2 - assert ( - spy_send_job.call_args.kwargs["request"]["changes"][0]["concreteType"] - == concrete_types.TABLE_SCHEMA_CHANGE_REQUEST - ) - assert ( - spy_send_job.call_args.kwargs["request"]["changes"][1]["concreteType"] - == concrete_types.UPLOAD_TO_TABLE_REQUEST - ) - - # AND I can query the table - results = query(f"SELECT * FROM {table.id}", synapse_client=self.syn) - - # AND the data in the columns should match - pd.testing.assert_series_equal( - results["column_string"], data_for_table["column_string"], check_dtype=False - ) - - # AND the column should have been expanded - assert table.columns["column_string"].maximum_size == 54 - - def test_store_rows_on_existing_table_adding_column( - self, mocker: MockerFixture, project_model: Project - ) -> None: - # SPYs - spy_csv_file_conversion = mocker.spy(table_module, "csv_to_pandas_df") - - # GIVEN a table with a column defined - table_name = str(uuid.uuid4()) - table = Table( - name=table_name, - parent_id=project_model.id, - columns=[Column(name="column_string", column_type=ColumnType.STRING)], - ) - - # AND the table exists in Synapse - table = table.store(synapse_client=self.syn) - self.schedule_for_cleanup(table.id) - spy_send_job = mocker.spy(asynchronous_job_module, "send_job_async") - - # AND data for a column stored to CSV - data_for_table = pd.DataFrame( - {"column_string": ["value1", "value2", "value3"], "column_key_2": [1, 2, 3]} - ) - filepath = f"{tempfile.mkdtemp()}/upload_{uuid.uuid4()}.csv" - self.schedule_for_cleanup(filepath) - data_for_table.to_csv(filepath, index=False, float_format="%.12g") - - # WHEN I store rows to the table with a schema storage strategy - table.store_rows( - values=filepath, - schema_storage_strategy=SchemaStorageStrategy.INFER_FROM_DATA, - synapse_client=self.syn, - ) - - # THEN the spy should have been called - spy_csv_file_conversion.assert_called_once() - - # AND the schema should not have been updated - assert len(spy_send_job.call_args.kwargs["request"]["changes"]) == 2 - assert ( - spy_send_job.call_args.kwargs["request"]["changes"][0]["concreteType"] - == concrete_types.TABLE_SCHEMA_CHANGE_REQUEST - ) - assert ( - spy_send_job.call_args.kwargs["request"]["changes"][1]["concreteType"] - == concrete_types.UPLOAD_TO_TABLE_REQUEST - ) - - # AND I can query the table - results = query(f"SELECT * FROM {table.id}", synapse_client=self.syn) - - # AND the data in the columns should match - pd.testing.assert_series_equal( - results["column_string"], data_for_table["column_string"], check_dtype=False - ) - pd.testing.assert_series_equal( - results["column_key_2"], data_for_table["column_key_2"], check_dtype=False - ) - - def test_store_rows_on_existing_table_no_schema_storage_strategy( - self, project_model: Project - ) -> None: - # GIVEN a table with a column defined - table_name = str(uuid.uuid4()) - table = Table( - name=table_name, - parent_id=project_model.id, - columns=[Column(name="column_string", column_type=ColumnType.STRING)], - ) - - # AND the table exists in Synapse - table = table.store(synapse_client=self.syn) - self.schedule_for_cleanup(table.id) - - # AND data for a column stored to CSV - data_for_table = pd.DataFrame( - {"column_string": ["value1", "value2", "value3"], "column_key_2": [1, 2, 3]} - ) - filepath = f"{tempfile.mkdtemp()}/upload_{uuid.uuid4()}.csv" - self.schedule_for_cleanup(filepath) - data_for_table.to_csv(filepath, index=False, float_format="%.12g") - - # WHEN I store rows to the table with no schema storage strategy - with pytest.raises(SynapseHTTPError) as e: - table.store_rows( - values=filepath, schema_storage_strategy=None, synapse_client=self.syn - ) - - # THEN the table data should fail to be inserted - assert ( - "400 Client Error: The first line is expected to be a header but the values do not match the names of of the columns of the table (column_key_2 is not a valid column name or id). Header row: column_string,column_key_2" - in str(e.value) - ) - - def test_store_rows_as_csv_being_split_and_uploaded( - self, project_model: Project, mocker: MockerFixture - ) -> None: - # GIVEN a table in Synapse - table_name = str(uuid.uuid4()) - table = Table( - name=table_name, - parent_id=project_model.id, - columns=[ - Column(name="column_string", column_type=ColumnType.STRING), - Column(name="column_to_order_on", column_type=ColumnType.INTEGER), - Column( - name="large_string", - column_type=ColumnType.STRING, - maximum_size=5, - ), - ], - ) - table = table.store(synapse_client=self.syn) - self.schedule_for_cleanup(table.id) - spy_send_job = mocker.spy(asynchronous_job_module, "send_job_async") - - # AND data that will be split into multiple parts - large_string_a = "A" * 5 - data_for_table = pd.DataFrame( - { - "column_string": [f"value{i}" for i in range(200)], - "column_to_order_on": [i for i in range(200)], - "large_string": [large_string_a for _ in range(200)], - } - ) - filepath = f"{tempfile.mkdtemp()}/upload_{uuid.uuid4()}.csv" - self.schedule_for_cleanup(filepath) - data_for_table.to_csv(filepath, index=False, float_format="%.12g") - - # WHEN I store the rows to the table - table.store_rows( - values=filepath, - schema_storage_strategy=None, - synapse_client=self.syn, - insert_size_bytes=1 * utils.KB, - ) - - # AND I query the table - results = query( - f"SELECT * FROM {table.id} ORDER BY column_to_order_on ASC", - synapse_client=self.syn, - ) - - # THEN the data in the columns should match - pd.testing.assert_series_equal( - results["column_string"], data_for_table["column_string"], check_dtype=False - ) - pd.testing.assert_series_equal( - results["column_to_order_on"], - data_for_table["column_to_order_on"], - check_dtype=False, - ) - pd.testing.assert_series_equal( - results["large_string"], data_for_table["large_string"], check_dtype=False - ) - - # AND 200 rows exist on the table - assert len(results) == 200 - - # AND The spy should have been called in multiple batches - assert spy_send_job.call_count == 5 - - def test_store_rows_as_df_being_split_and_uploaded( - self, project_model: Project, mocker: MockerFixture - ) -> None: - # GIVEN a table in Synapse - table_name = str(uuid.uuid4()) - table = Table( - name=table_name, - parent_id=project_model.id, - columns=[ - Column(name="column_string", column_type=ColumnType.STRING), - Column(name="column_to_order_on", column_type=ColumnType.INTEGER), - Column( - name="large_string", - column_type=ColumnType.STRING, - maximum_size=5, - ), - ], - ) - table = table.store(synapse_client=self.syn) - self.schedule_for_cleanup(table.id) - spy_send_job = mocker.spy(asynchronous_job_module, "send_job_async") - - # AND data that will be split into multiple parts - large_string_a = "A" * 5 - data_for_table = pd.DataFrame( - { - "column_string": [f"value{i}" for i in range(200)], - "column_to_order_on": [i for i in range(200)], - "large_string": [large_string_a for _ in range(200)], - } - ) - - # WHEN I store the rows to the table - table.store_rows( - values=data_for_table, - schema_storage_strategy=None, - synapse_client=self.syn, - insert_size_bytes=1 * utils.KB, - ) - - # AND I query the table - results = query( - f"SELECT * FROM {table.id} ORDER BY column_to_order_on ASC", - synapse_client=self.syn, - ) - - # THEN the data in the columns should match - pd.testing.assert_series_equal( - results["column_string"], data_for_table["column_string"], check_dtype=False - ) - pd.testing.assert_series_equal( - results["column_to_order_on"], - data_for_table["column_to_order_on"], - check_dtype=False, - ) - pd.testing.assert_series_equal( - results["large_string"], data_for_table["large_string"], check_dtype=False - ) - - # AND 200 rows exist on the table - assert len(results) == 200 - - # AND The spy should have been called in multiple batches - # Note: DataFrames have a minimum of 100 rows per batch - assert spy_send_job.call_count == 3 - - @skip("Skip in normal testing because the large size makes it slow") - def test_store_rows_as_large_df_being_split_and_uploaded( - self, project_model: Project, mocker: MockerFixture - ) -> None: - # GIVEN a table in Synapse - table_name = str(uuid.uuid4()) - table = Table( - name=table_name, - parent_id=project_model.id, - columns=[ - Column(name="column_string", column_type=ColumnType.STRING), - Column(name="column_to_order_on", column_type=ColumnType.INTEGER), - Column( - name="large_string", - column_type=ColumnType.LARGETEXT, - ), - ], - ) - table = table.store(synapse_client=self.syn) - self.schedule_for_cleanup(table.id) - spy_send_job = mocker.spy(asynchronous_job_module, "send_job") - - # AND data that will be split into multiple parts - rows_in_table = 20 - random_string = "".join(random.choices(string.ascii_uppercase, k=500000)) - data_for_table = pd.DataFrame( - { - "column_string": [f"value{i}" for i in range(rows_in_table)], - "column_to_order_on": [i for i in range(rows_in_table)], - "large_string": [random_string for _ in range(rows_in_table)], - } - ) - - # WHEN I store the rows to the table - table.store_rows( - values=data_for_table, - schema_storage_strategy=None, - synapse_client=self.syn, - insert_size_bytes=1 * utils.KB, - ) - - # AND I query the table - results = query( - f"SELECT * FROM {table.id} ORDER BY column_to_order_on ASC", - synapse_client=self.syn, - ) - - # THEN the data in the columns should match - pd.testing.assert_series_equal( - results["column_string"], data_for_table["column_string"], check_dtype=False - ) - pd.testing.assert_series_equal( - results["column_to_order_on"], - data_for_table["column_to_order_on"], - check_dtype=False, - ) - pd.testing.assert_series_equal( - results["large_string"], data_for_table["large_string"], check_dtype=False - ) - - # AND `rows_in_table` rows exist on the table - assert len(results) == rows_in_table - - # AND The spy should have been called in multiple batches - assert spy_send_job.call_count == 1 - - -class TestUpsertRows: - @pytest.fixture(autouse=True, scope="function") - def init(self, syn: Synapse, schedule_for_cleanup: Callable[..., None]) -> None: - self.syn = syn - self.schedule_for_cleanup = schedule_for_cleanup - - def test_upsert_operations_with_various_data_sources( - self, project_model: Project, mocker: MockerFixture - ) -> None: - """Test various upsert operations with different data sources and options.""" - # GIVEN a table in Synapse - table_name = str(uuid.uuid4()) - table = Table( - name=table_name, - parent_id=project_model.id, - columns=[ - Column(name="column_string", column_type=ColumnType.STRING), - Column(name="column_key_2", column_type=ColumnType.INTEGER), - ], - ) - table = table.store(synapse_client=self.syn) - self.schedule_for_cleanup(table.id) - - # AND data for a column already stored in Synapse - initial_data = pd.DataFrame( - {"column_string": ["value1", "value2", "value3"], "column_key_2": [1, 2, 3]} - ) - table.store_rows( - values=initial_data, schema_storage_strategy=None, synapse_client=self.syn - ) - spy_send_job = mocker.spy(asynchronous_job_module, "send_job_async") - - # Test 1: Basic update with no insertions - # WHEN I upsert rows with modified values but no new rows - updated_data = pd.DataFrame( - {"column_string": ["value1", "value2", "value3"], "column_key_2": [4, 5, 6]} - ) - table.upsert_rows( - values=updated_data, - primary_keys=["column_string"], - synapse_client=self.syn, - ) - - # THEN the values should be updated with no new rows - results = query(f"SELECT * FROM {table.id}", synapse_client=self.syn) - pd.testing.assert_series_equal( - results["column_string"], updated_data["column_string"], check_dtype=False - ) - pd.testing.assert_series_equal( - results["column_key_2"], updated_data["column_key_2"], check_dtype=False - ) - assert len(results) == 3 - - # Test 2: Upsert with updates and insertions from CSV - # WHEN I upsert rows with modified values and new rows from CSV - updated_and_new_data = pd.DataFrame( - { - "column_string": [ - "value1", - "value2", - "value3", - "value4", - "value5", - "value6", - ], - "column_key_2": [10, 11, 12, 13, 14, 15], - } - ) - filepath = f"{tempfile.mkdtemp()}/upload_{uuid.uuid4()}.csv" - self.schedule_for_cleanup(filepath) - updated_and_new_data.to_csv(filepath, index=False, float_format="%.12g") - - table.upsert_rows( - values=filepath, - primary_keys=["column_string"], - synapse_client=self.syn, - ) - - # THEN the values should be updated and new rows added - results = query(f"SELECT * FROM {table.id}", synapse_client=self.syn) - pd.testing.assert_series_equal( - results["column_string"], - updated_and_new_data["column_string"], - check_dtype=False, - ) - pd.testing.assert_series_equal( - results["column_key_2"], - updated_and_new_data["column_key_2"], - check_dtype=False, - ) - assert len(results) == 6 # 3 original + 3 new - - # Test 3: Upsert with dictionary data source - # WHEN I upsert rows with dictionary data - dict_data = { - "column_string": [ - "value1", - "value2", - "value3", - "value7", - "value8", - "value9", - ], - "column_key_2": [20, 21, 22, 23, 24, 25], - } - - # Reset the spy to count just this operation - spy_send_job.reset_mock() - - table.upsert_rows( - values=dict_data, - primary_keys=["column_string"], - synapse_client=self.syn, - ) - - # THEN the values should be updated and new rows added - results = query(f"SELECT * FROM {table.id}", synapse_client=self.syn) - # We should have 9 total rows now (6 from before + 3 new) - assert len(results) == 9 - # The spy should have been called for update and insert operations - assert spy_send_job.call_count == 4 - - # Test 4: Dry run operation - # WHEN I perform a dry run upsert - dry_run_data = pd.DataFrame( - { - "column_string": ["value1", "value2", "value3"], - "column_key_2": [99, 99, 99], - } - ) - - spy_table_update = mocker.spy(table_module, "_push_row_updates_to_synapse") - - table.upsert_rows( - values=dry_run_data, - primary_keys=["column_string"], - dry_run=True, - synapse_client=self.syn, - ) - - # THEN no changes should be applied - results = query(f"SELECT * FROM {table.id}", synapse_client=self.syn) - # Should still have 9 rows - assert len(results) == 9 - # The values from the previous update should still be in place - assert 99 not in results["column_key_2"].values - # The spy should not have been called - assert spy_table_update.call_count == 0 - - def test_upsert_with_multi_value_key(self, project_model: Project) -> None: - """Test upserting rows using multiple columns as the primary key.""" - # GIVEN a table in Synapse - table_name = str(uuid.uuid4()) - table = Table( - name=table_name, - parent_id=project_model.id, - columns=[ - Column(name="column_string", column_type=ColumnType.STRING), - Column(name="column_key_2", column_type=ColumnType.INTEGER), - Column(name="column_key_3", column_type=ColumnType.BOOLEAN), - ], - ) - table = table.store(synapse_client=self.syn) - self.schedule_for_cleanup(table.id) - - # AND data for a column already stored in Synapse - data_for_table = pd.DataFrame( - { - "column_string": ["value1", "value2", "value3"], - "column_key_2": [1, 2, 3], - "column_key_3": [True, True, True], - } - ) - table.store_rows( - values=data_for_table, schema_storage_strategy=None, synapse_client=self.syn - ) - - # WHEN I upsert rows with matching keys - modified_data_for_table = pd.DataFrame( - { - "column_string": [ - "value1", - "value2", - "value3", - "value4", - "value5", - "value6", - ], - "column_key_2": [1, 2, 3, 4, 5, 6], - "column_key_3": [False, False, False, False, False, False], - } - ) - table.upsert_rows( - values=modified_data_for_table, - primary_keys=["column_string", "column_key_2"], - synapse_client=self.syn, - ) - - # THEN matching rows should be updated and new rows added - results = query(f"SELECT * FROM {table.id}", synapse_client=self.syn) - pd.testing.assert_series_equal( - results["column_string"], - modified_data_for_table["column_string"], - check_dtype=False, - ) - pd.testing.assert_series_equal( - results["column_key_2"], - modified_data_for_table["column_key_2"], - check_dtype=False, - ) - pd.testing.assert_series_equal( - results["column_key_3"], - modified_data_for_table["column_key_3"], - check_dtype=False, - ) - assert len(results) == 6 # 3 updated + 3 new - - # WHEN I upsert rows with non-matching keys - data_for_insertion_only = pd.DataFrame( - { - "column_string": [ - "value1", - "value2", - "value3", - ], - "column_key_2": [7, 8, 9], # Different key values - "column_key_3": [True, True, True], - } - ) - table.upsert_rows( - values=data_for_insertion_only, - primary_keys=["column_string", "column_key_2"], - synapse_client=self.syn, - ) - - # THEN all rows should be inserted (no updates) - results = query(f"SELECT * FROM {table.id}", synapse_client=self.syn) - # Should have 9 rows now (6 from before + 3 new) - assert len(results) == 9 - - def test_upsert_with_large_data_and_batching( - self, project_model: Project, mocker: MockerFixture - ) -> None: - """Test upserting with large data strings that require batching.""" - # GIVEN a table in Synapse with a large string column - table_name = str(uuid.uuid4()) - table = Table( - name=table_name, - parent_id=project_model.id, - columns=[ - Column(name="column_string", column_type=ColumnType.STRING), - Column(name="column_key_2", column_type=ColumnType.INTEGER), - Column( - name="large_string", - column_type=ColumnType.STRING, - maximum_size=1000, - ), - ], - ) - table = table.store(synapse_client=self.syn) - self.schedule_for_cleanup(table.id) - - # AND data for a column already stored in Synapse - large_string_a = "A" * 1000 - data_for_table = pd.DataFrame( - { - "column_string": ["value1", "value2", "value3"], - "column_key_2": [1, 2, 3], - "large_string": [large_string_a, large_string_a, large_string_a], - } - ) - table.store_rows( - values=data_for_table, schema_storage_strategy=None, synapse_client=self.syn - ) - spy_send_job = mocker.spy(asynchronous_job_module, "send_job_async") - - # WHEN I upsert rows with large data and control batch size - large_string_b = "B" * 1000 - modified_data_for_table = pd.DataFrame( - { - "column_string": [ - "value1", - "value2", - "value3", - "value4", - "value5", - "value6", - ], - "column_key_2": [4, 5, 6, 7, 8, 9], - "large_string": [ - large_string_b, - large_string_b, - large_string_b, - large_string_b, - large_string_b, - large_string_b, - ], - } - ) - - table.upsert_rows( - values=modified_data_for_table, - primary_keys=["column_string"], - synapse_client=self.syn, - rows_per_request=1, - update_size_bytes=1 * utils.KB, - insert_size_bytes=1 * utils.KB, - ) - - # THEN all rows should be updated or inserted correctly - results = query(f"SELECT * FROM {table.id}", synapse_client=self.syn) - pd.testing.assert_series_equal( - results["column_string"], - modified_data_for_table["column_string"], - check_dtype=False, - ) - pd.testing.assert_series_equal( - results["column_key_2"], - modified_data_for_table["column_key_2"], - check_dtype=False, - ) - pd.testing.assert_series_equal( - results["large_string"], - modified_data_for_table["large_string"], - check_dtype=False, - ) - assert len(results) == 6 - - # AND multiple batch jobs should have been created due to batching settings - assert spy_send_job.call_count == 7 # More batches due to small size settings - - def test_upsert_all_data_types(self, project_model: Project) -> None: - """Test upserting all supported data types to ensure type compatibility.""" - # GIVEN a table in Synapse with all data types - table_name = str(uuid.uuid4()) - table = Table( - name=table_name, - parent_id=project_model.id, - columns=[ - Column(name="column_string", column_type=ColumnType.STRING), - Column(name="column_double", column_type=ColumnType.DOUBLE), - Column(name="column_integer", column_type=ColumnType.INTEGER), - Column(name="column_boolean", column_type=ColumnType.BOOLEAN), - Column(name="column_date", column_type=ColumnType.DATE), - Column(name="column_filehandleid", column_type=ColumnType.FILEHANDLEID), - Column(name="column_entityid", column_type=ColumnType.ENTITYID), - Column(name="column_submissionid", column_type=ColumnType.SUBMISSIONID), - Column(name="column_evaluationid", column_type=ColumnType.EVALUATIONID), - Column(name="column_link", column_type=ColumnType.LINK), - Column(name="column_mediumtext", column_type=ColumnType.MEDIUMTEXT), - Column(name="column_largetext", column_type=ColumnType.LARGETEXT), - Column(name="column_userid", column_type=ColumnType.USERID), - Column(name="column_string_LIST", column_type=ColumnType.STRING_LIST), - Column(name="column_integer_LIST", column_type=ColumnType.INTEGER_LIST), - Column(name="column_boolean_LIST", column_type=ColumnType.BOOLEAN_LIST), - Column(name="column_date_LIST", column_type=ColumnType.DATE_LIST), - Column( - name="column_entity_id_list", column_type=ColumnType.ENTITYID_LIST - ), - Column(name="column_user_id_list", column_type=ColumnType.USERID_LIST), - Column(name="column_json", column_type=ColumnType.JSON), - ], - ) - table = table.store(synapse_client=self.syn) - self.schedule_for_cleanup(table.id) - - # Set up test resources - path = utils.make_bogus_data_file() - self.schedule_for_cleanup(path) - file = File(parent_id=project_model.id, path=path).store( - synapse_client=self.syn - ) - - # Create evaluation for testing - name = "Test Evaluation %s" % str(uuid.uuid4()) - evaluation = Evaluation( - name=name, - description="Evaluation for testing", - contentSource=project_model.id, - ) - # TODO: When Evaluation and Submission are implemented with Async methods update this test - evaluation = self.syn.store(evaluation) - try: - submission = self.syn.submit( - evaluation, file.id, name="Submission 1", submitterAlias="My Team" - ) - - # GIVEN initial data with all data types, including random null values - initial_data = pd.DataFrame( - { - # Basic types - "column_string": ["value1", "value2", "value3"], - "column_double": [1.1, None, 2.2], - "column_integer": [1, None, 3], - "column_boolean": [True, None, True], - "column_date": [ - utils.to_unix_epoch_time("2021-01-01"), - None, - utils.to_unix_epoch_time("2021-01-03"), - ], - # Reference types - "column_filehandleid": [ - file.file_handle.id, - None, - file.file_handle.id, - ], - "column_entityid": [file.id, None, file.id], - "column_submissionid": [ - submission.id, - None, - submission.id, - ], - "column_evaluationid": [ - evaluation.id, - None, - evaluation.id, - ], - # Text types - "column_link": [ - "https://www.synapse.org/Profile:", - None, - "https://www.synapse.org/Profile:", - ], - "column_mediumtext": ["value1", None, "value3"], - "column_largetext": ["value1", None, "value3"], - # User IDs - "column_userid": [ - self.syn.credentials.owner_id, - None, - self.syn.credentials.owner_id, - ], - # List types - "column_string_LIST": [ - ["value1", "value2"], - None, - ["value5", "value6"], - ], - "column_integer_LIST": [[1, 2], None, [5, 6]], - "column_boolean_LIST": [ - [True, False], - None, - [True, False], - ], - "column_date_LIST": [ - [ - utils.to_unix_epoch_time("2021-01-01"), - utils.to_unix_epoch_time("2021-01-02"), - ], - None, - [ - utils.to_unix_epoch_time("2021-01-05"), - utils.to_unix_epoch_time("2021-01-06"), - ], - ], - "column_entity_id_list": [ - [file.id, file.id], - None, - [file.id, file.id], - ], - "column_user_id_list": [ - [self.syn.credentials.owner_id, self.syn.credentials.owner_id], - None, - [self.syn.credentials.owner_id, self.syn.credentials.owner_id], - ], - # JSON type - "column_json": [ - {"key1": "value1", "key2": 2}, - None, - {"key5": "value5", "key6": 6}, - ], - } - ) - - # Store initial data - table.store_rows( - values=initial_data, - schema_storage_strategy=None, - synapse_client=self.syn, - ) - - # THEN verify the initial data was stored correctly - results_after_insert = query( - f"SELECT * FROM {table.id}", - synapse_client=self.syn, - include_row_id_and_row_version=False, - ) - - # Verify data types and values match for all columns - assert len(results_after_insert) == 3 - # expected dataframe - expected_results = pd.DataFrame( - { - "column_string": ["value1", "value2", "value3"], - "column_double": [1.1, None, 2.2], - "column_integer": [1, None, 3], - "column_boolean": [True, None, True], - "column_date": [ - utils.to_unix_epoch_time("2021-01-01"), - None, - utils.to_unix_epoch_time("2021-01-03"), - ], - "column_filehandleid": [ - file.file_handle.id, - None, - file.file_handle.id, - ], - "column_entityid": [file.id, None, file.id], - "column_submissionid": [submission.id, None, submission.id], - "column_evaluationid": [evaluation.id, None, evaluation.id], - "column_link": [ - "https://www.synapse.org/Profile:", - None, - "https://www.synapse.org/Profile:", - ], - "column_mediumtext": ["value1", None, "value3"], - "column_largetext": ["value1", None, "value3"], - "column_userid": [ - self.syn.credentials.owner_id, - None, - self.syn.credentials.owner_id, - ], - "column_string_LIST": [ - ["value1", "value2"], - [], - ["value5", "value6"], - ], - "column_integer_LIST": [[1, 2], [], [5, 6]], - "column_boolean_LIST": [ - [True, False], - [], - [True, False], - ], # empty values to [] in csv_to_pandas_df - "column_date_LIST": [ - [ - utils.to_unix_epoch_time("2021-01-01"), - utils.to_unix_epoch_time("2021-01-02"), - ], - [], - [ - utils.to_unix_epoch_time("2021-01-05"), - utils.to_unix_epoch_time("2021-01-06"), - ], - ], - "column_entity_id_list": [ - [file.id, file.id], - [], - [file.id, file.id], - ], - "column_user_id_list": [ - [self.syn.credentials.owner_id, self.syn.credentials.owner_id], - [], - [self.syn.credentials.owner_id, self.syn.credentials.owner_id], - ], - "column_json": [ - {"key1": "value1", "key2": 2}, - [], - {"key5": "value5", "key6": 6}, - ], - } - ) - pd.testing.assert_frame_equal( - results_after_insert, expected_results, check_dtype=False - ) - - # Create a second test file to update references - path2 = utils.make_bogus_data_file() - self.schedule_for_cleanup(path2) - file2: File = File(parent_id=project_model.id, path=path2).store( - synapse_client=self.syn - ) - - # WHEN I upsert with updated data for all types, including null values - updated_data = pd.DataFrame( - { - # Basic types with updated values - "column_string": ["value1", "value2", "value3"], - "column_double": [11.2, None, 33.4], - "column_integer": [11, None, 33], - "column_boolean": [False, None, False], - "column_date": [ - utils.to_unix_epoch_time("2022-01-01"), - None, - utils.to_unix_epoch_time("2022-01-03"), - ], - # Updated references - "column_filehandleid": [ - file2.file_handle.id, - None, - file2.file_handle.id, - ], - "column_entityid": [file2.id, None, file2.id], - "column_submissionid": [ - submission.id, - None, - submission.id, - ], - "column_evaluationid": [ - evaluation.id, - None, - evaluation.id, - ], - # Updated text - "column_link": [ - "https://www.synapse.org/", - None, - "https://www.synapse.org/", - ], - "column_mediumtext": ["value11", None, "value33"], - "column_largetext": ["value11", None, "value33"], - # User IDs - "column_userid": [ - self.syn.credentials.owner_id, - None, - self.syn.credentials.owner_id, - ], - # Updated list types - "column_string_LIST": [ - ["value11", "value22"], - None, - ["value55", "value66"], - ], - "column_integer_LIST": [[11, 22], None, [55, 66]], - "column_boolean_LIST": [ - [False, True], - None, - [False, True], - ], - "column_date_LIST": [ - [ - utils.to_unix_epoch_time("2022-01-01"), - utils.to_unix_epoch_time("2022-01-02"), - ], - None, - [ - utils.to_unix_epoch_time("2022-01-05"), - utils.to_unix_epoch_time("2022-01-06"), - ], - ], - "column_entity_id_list": [ - [file2.id, file2.id], - None, - [file2.id, file2.id], - ], - "column_user_id_list": [ - [ - self.syn.credentials.owner_id, - self.syn.credentials.owner_id, - ], - None, - [ - self.syn.credentials.owner_id, - self.syn.credentials.owner_id, - ], - ], - # JSON - "column_json": [ - json.dumps({"key11": "value11", "key22": 22}), - None, - json.dumps({"key55": "value55", "key66": 66}), - ], - } - ) - - # Perform upsert based on string column - table.upsert_rows( - values=updated_data, - primary_keys=["column_string"], - synapse_client=self.syn, - ) - - # THEN all data types should be correctly updated - results = query( - f"SELECT * FROM {table.id}", - synapse_client=self.syn, - include_row_id_and_row_version=False, - ) - - # Verify the upserted data matches expected values and handles nulls correctly - assert len(results) == 3 - - # expected dataframe - expected_results = pd.DataFrame( - { - "column_string": ["value1", "value2", "value3"], - "column_double": [11.2, None, 33.4], - "column_integer": [11, None, 33], - "column_boolean": [False, None, False], - "column_date": [ - utils.to_unix_epoch_time("2022-01-01"), - None, - utils.to_unix_epoch_time("2022-01-03"), - ], - "column_filehandleid": [ - file2.file_handle.id, - None, - file2.file_handle.id, - ], - "column_entityid": [file2.id, None, file2.id], - "column_submissionid": [submission.id, None, submission.id], - "column_evaluationid": [evaluation.id, None, evaluation.id], - "column_link": [ - "https://www.synapse.org/", - None, - "https://www.synapse.org/", - ], - "column_mediumtext": ["value11", None, "value33"], - "column_largetext": ["value11", None, "value33"], - "column_userid": [ - self.syn.credentials.owner_id, - None, - self.syn.credentials.owner_id, - ], - "column_string_LIST": [ - ["value11", "value22"], - [], - ["value55", "value66"], - ], - "column_integer_LIST": [[11, 22], [], [55, 66]], - "column_boolean_LIST": [[False, True], [], [False, True]], - "column_date_LIST": [ - [ - utils.to_unix_epoch_time("2022-01-01"), - utils.to_unix_epoch_time("2022-01-02"), - ], - [], - [ - utils.to_unix_epoch_time("2022-01-05"), - utils.to_unix_epoch_time("2022-01-06"), - ], - ], - "column_entity_id_list": [ - [file2.id, file2.id], - [], - [file2.id, file2.id], - ], - "column_user_id_list": [ - [self.syn.credentials.owner_id, self.syn.credentials.owner_id], - [], - [self.syn.credentials.owner_id, self.syn.credentials.owner_id], - ], - "column_json": [ - {"key11": "value11", "key22": 22}, - [], - {"key55": "value55", "key66": 66}, - ], - } - ) - pd.testing.assert_frame_equal(results, expected_results, check_dtype=False) - - # WHEN I upsert with multiple primary keys and null values - multi_key_data = pd.DataFrame( - { - # Just using a subset of columns for this test case - "column_string": ["this", "is", "updated"], - "column_double": [1.1, 2.2, 3.3], - "column_integer": [1, 2, 3], - "column_boolean": [True, True, True], - "column_date": [ - utils.to_unix_epoch_time("2021-01-01"), - utils.to_unix_epoch_time("2021-01-02"), - utils.to_unix_epoch_time("2021-01-03"), - ], - "column_filehandleid": [ - file.file_handle.id, - None, - file.file_handle.id, - ], - "column_entityid": [file.id, None, file.id], - "column_submissionid": [ - submission.id, - None, - submission.id, - ], - "column_evaluationid": [ - evaluation.id, - None, - evaluation.id, - ], - "column_link": [ - "https://www.synapse.org/", - None, - "https://www.synapse.org/", - ], - "column_mediumtext": ["updated1", None, "updated3"], - "column_largetext": ["largetext1", None, "largetext3"], - "column_userid": [ - self.syn.credentials.owner_id, - None, - self.syn.credentials.owner_id, - ], - # Simplified list data - "column_string_LIST": [ - ["a", "b"], - None, - ["e", "f"], - ], - "column_integer_LIST": [[9, 8], None, [5, 4]], - "column_boolean_LIST": [ - [True, True], - None, - [True, True], - ], - "column_date_LIST": [ - [ - utils.to_unix_epoch_time("2023-01-01"), - utils.to_unix_epoch_time("2023-01-02"), - ], - None, - [ - utils.to_unix_epoch_time("2023-01-05"), - utils.to_unix_epoch_time("2023-01-06"), - ], - ], - "column_entity_id_list": [ - [file.id, file.id], - None, - [file.id, file.id], - ], - "column_user_id_list": [ - [ - self.syn.credentials.owner_id, - self.syn.credentials.owner_id, - ], - None, - [ - self.syn.credentials.owner_id, - self.syn.credentials.owner_id, - ], - ], - "column_json": [ - json.dumps({"final1": "value1"}), - None, - json.dumps({"final3": "value3"}), - ], - } - ) - - # Test multiple primary keys - primary_keys = [ - "column_double", - "column_integer", - "column_boolean", - "column_date", - ] - - table.upsert_rows( - values=multi_key_data, - primary_keys=primary_keys, - synapse_client=self.syn, - ) - - # THEN the new rows should be added (not updating existing) - results_after_multi_key = query( - f"SELECT * FROM {table.id}", - synapse_client=self.syn, - include_row_id_and_row_version=False, - ) - - # We should have more rows now (original 3 + 3 new ones) - assert len(results_after_multi_key) == 6 - - # Verify that null values are properly handled in the newly inserted rows - # Find the rows with the new string values - new_rows = results_after_multi_key[ - results_after_multi_key["column_string"].isin(["this", "is", "updated"]) - ] - assert len(new_rows) == 3 - - for _, row in new_rows.iterrows(): - if row["column_string"] == "this": - assert row["column_double"] == 1.1 - assert row["column_integer"] == 1 - assert row["column_boolean"] is True - assert row["column_date"] == utils.to_unix_epoch_time("2021-01-01") - assert row["column_filehandleid"] == file.file_handle.id - assert row["column_entityid"] == file.id - assert row["column_mediumtext"] == "updated1" - assert row["column_largetext"] == "largetext1" - assert row["column_userid"] == self.syn.credentials.owner_id - assert row["column_string_LIST"] == ["a", "b"] - assert row["column_integer_LIST"] == [9, 8] - assert row["column_boolean_LIST"] == [True, True] - assert row["column_date_LIST"] == [ - utils.to_unix_epoch_time("2023-01-01"), - utils.to_unix_epoch_time("2023-01-02"), - ] - assert row["column_json"] == {"final1": "value1"} - elif row["column_string"] == "is": - assert row["column_double"] == 2.2 - assert row["column_integer"] == 2 - assert row["column_boolean"] is True - assert row["column_date"] == utils.to_unix_epoch_time("2021-01-02") - assert pd.isna(row["column_filehandleid"]) - assert pd.isna(row["column_entityid"]) - assert pd.isna(row["column_mediumtext"]) - assert pd.isna(row["column_largetext"]) - assert pd.isna(row["column_userid"]) - assert len(row["column_string_LIST"]) == 0 - assert len(row["column_integer_LIST"]) == 0 - assert len(row["column_boolean_LIST"]) == 0 - assert len(row["column_date_LIST"]) == 0 - assert len(row["column_json"]) == 0 - elif row["column_string"] == "updated": - assert row["column_double"] == 3.3 - assert row["column_integer"] == 3 - assert row["column_boolean"] is True - assert row["column_date"] == utils.to_unix_epoch_time("2021-01-03") - assert row["column_filehandleid"] == file.file_handle.id - assert row["column_entityid"] == file.id - assert row["column_mediumtext"] == "updated3" - assert row["column_largetext"] == "largetext3" - - finally: - # Clean up - self.syn.delete(evaluation) - - -class TestDeleteRows: - @pytest.fixture(autouse=True, scope="function") - def init(self, syn: Synapse, schedule_for_cleanup: Callable[..., None]) -> None: - self.syn = syn - self.schedule_for_cleanup = schedule_for_cleanup - - def test_delete_single_row(self, project_model: Project) -> None: - # GIVEN a table in Synapse - table_name = str(uuid.uuid4()) - table = Table( - name=table_name, - parent_id=project_model.id, - columns=[Column(name="column_string", column_type=ColumnType.STRING)], - ) - table = table.store(synapse_client=self.syn) - self.schedule_for_cleanup(table.id) - - # AND data for a column already stored in Synapse - data_for_table = pd.DataFrame({"column_string": ["value1", "value2", "value3"]}) - table.store_rows( - values=data_for_table, schema_storage_strategy=None, synapse_client=self.syn - ) - - # WHEN I delete a single row from the table - table.delete_rows( - query=f"SELECT ROW_ID, ROW_VERSION FROM {table.id} WHERE column_string = 'value2'", - synapse_client=self.syn, - ) - - # AND I query the table - results = query(f"SELECT * FROM {table.id}", synapse_client=self.syn) - - # THEN the data in the columns should match - pd.testing.assert_series_equal( - results["column_string"], - pd.DataFrame({"column_string": ["value1", "value3"]})["column_string"], - check_dtype=False, - ) - - # AND only 2 rows should exist on the table - assert len(results) == 2 - - def test_delete_multiple_rows(self, project_model: Project) -> None: - # GIVEN a table in Synapse - table_name = str(uuid.uuid4()) - table = Table( - name=table_name, - parent_id=project_model.id, - columns=[Column(name="column_string", column_type=ColumnType.STRING)], - ) - table = table.store(synapse_client=self.syn) - self.schedule_for_cleanup(table.id) - - # AND data for a column already stored in Synapse - data_for_table = pd.DataFrame({"column_string": ["value1", "value2", "value3"]}) - table.store_rows( - values=data_for_table, schema_storage_strategy=None, synapse_client=self.syn - ) - - # WHEN I delete a single row from the table - table.delete_rows( - query=f"SELECT ROW_ID, ROW_VERSION FROM {table.id} WHERE column_string IN ('value2','value3')", - synapse_client=self.syn, - ) - - # AND I query the table - results = query(f"SELECT * FROM {table.id}", synapse_client=self.syn) - - # THEN the data in the columns should match - pd.testing.assert_series_equal( - results["column_string"], - pd.DataFrame({"column_string": ["value1"]})["column_string"], - check_dtype=False, - ) - - # AND only 1 row should exist on the table - assert len(results) == 1 - - def test_delete_no_rows(self, project_model: Project) -> None: - # GIVEN a table in Synapse - table_name = str(uuid.uuid4()) - table = Table( - name=table_name, - parent_id=project_model.id, - columns=[Column(name="column_string", column_type=ColumnType.STRING)], - ) - table = table.store(synapse_client=self.syn) - self.schedule_for_cleanup(table.id) - - # AND data for a column already stored in Synapse - data_for_table = pd.DataFrame({"column_string": ["value1", "value2", "value3"]}) - table.store_rows( - values=data_for_table, schema_storage_strategy=None, synapse_client=self.syn - ) - - # WHEN I delete a single row from the table - table.delete_rows( - query=f"SELECT ROW_ID, ROW_VERSION FROM {table.id} WHERE column_string = 'foo'", - synapse_client=self.syn, - ) - - # AND I query the table - results = query(f"SELECT * FROM {table.id}", synapse_client=self.syn) - - # THEN the data in the columns should match - pd.testing.assert_series_equal( - results["column_string"], data_for_table["column_string"], check_dtype=False - ) - - # AND 3 rows should exist on the table - assert len(results) == 3 - - -class TestColumnModifications: - @pytest.fixture(autouse=True, scope="function") - def init(self, syn: Synapse, schedule_for_cleanup: Callable[..., None]) -> None: - self.syn = syn - self.schedule_for_cleanup = schedule_for_cleanup - - def test_column_rename(self, project_model: Project) -> None: - # GIVEN a table in Synapse - table_name = str(uuid.uuid4()) - old_column_name = "column_string" - old_table_instance = Table( - name=table_name, - parent_id=project_model.id, - columns=[Column(name=old_column_name, column_type=ColumnType.STRING)], - ) - old_table_instance = old_table_instance.store(synapse_client=self.syn) - self.schedule_for_cleanup(old_table_instance.id) - - # WHEN I rename the column - new_column_name = "new_column_string" - old_table_instance.columns[old_column_name].name = new_column_name - - # AND I store the table - old_table_instance.store(synapse_client=self.syn) - - # THEN the column name should be updated on the existing table instance - assert old_table_instance.columns[new_column_name] is not None - assert old_column_name not in old_table_instance.columns - - # AND the new column name should be reflected in the Synapse table - new_table_instance = Table(id=old_table_instance.id).get( - synapse_client=self.syn - ) - assert new_table_instance.columns[new_column_name] is not None - assert old_column_name not in new_table_instance.columns - - def test_delete_column(self, project_model: Project) -> None: - # GIVEN a table in Synapse - table_name = str(uuid.uuid4()) - old_column_name = "column_string" - column_to_keep = "column_to_keep" - old_table_instance = Table( - name=table_name, - parent_id=project_model.id, - columns=[ - Column(name=old_column_name, column_type=ColumnType.STRING), - Column(name=column_to_keep, column_type=ColumnType.STRING), - ], - ) - old_table_instance = old_table_instance.store(synapse_client=self.syn) - self.schedule_for_cleanup(old_table_instance.id) - - # WHEN I delete the column - old_table_instance.delete_column(name=old_column_name) - - # AND I store the table - old_table_instance.store(synapse_client=self.syn) - - # THEN the column should be removed from the table instance - assert old_column_name not in old_table_instance.columns - - # AND the column to keep should still be in the table instance - assert column_to_keep in old_table_instance.columns - assert len(old_table_instance.columns.values()) == 1 - - # AND the column should be removed from the Synapse table - new_table_instance = Table(id=old_table_instance.id).get( - synapse_client=self.syn - ) - assert old_column_name not in new_table_instance.columns - - # AND the column to keep should still be in the Synapse table - assert column_to_keep in new_table_instance.columns - assert len(new_table_instance.columns.values()) == 1 - - -class TestQuerying: - @pytest.fixture(autouse=True, scope="function") - def init(self, syn: Synapse, schedule_for_cleanup: Callable[..., None]) -> None: - self.syn = syn - self.schedule_for_cleanup = schedule_for_cleanup - - def test_query_to_csv(self, project_model: Project) -> None: - # GIVEN a table with a column defined - table_name = str(uuid.uuid4()) - table = Table( - name=table_name, - parent_id=project_model.id, - columns=[ - Column(name="column_string", column_type=ColumnType.STRING), - Column(name="integer_column", column_type=ColumnType.INTEGER), - Column(name="float_column", column_type=ColumnType.DOUBLE), - ], - ) - table = table.store(synapse_client=self.syn) - self.schedule_for_cleanup(table.id) - - # AND data for the table stored in synapse - data_for_table = pd.DataFrame( - { - "column_string": ["value1", "value2", "value3", "value4"], - "integer_column": [1, 2, 3, None], - "float_column": [1.1, 2.2, 3.3, None], - } - ) - table.store_rows( - values=data_for_table, schema_storage_strategy=None, synapse_client=self.syn - ) - - # WHEN I query the table with a temporary directory - with tempfile.TemporaryDirectory() as temp_dir_name: - results = query( - query=f"SELECT * FROM {table.id}", - synapse_client=self.syn, - download_location=temp_dir_name, - ) - # THEN The returned result should be a path to the CSV - assert isinstance(results, str) - assert os.path.basename(results).endswith(".csv") - as_dataframe = pd.read_csv(results) - - # AND the data in the columns should match - pd.testing.assert_series_equal( - as_dataframe["column_string"], data_for_table["column_string"] - ) - pd.testing.assert_series_equal( - as_dataframe["integer_column"], data_for_table["integer_column"] - ) - pd.testing.assert_series_equal( - as_dataframe["float_column"], data_for_table["float_column"] - ) - - def test_part_mask_query_everything(self, project_model: Project) -> None: - # GIVEN a table with a column defined - table_name = str(uuid.uuid4()) - table = Table( - name=table_name, - parent_id=project_model.id, - columns=[ - Column(name="column_string", column_type=ColumnType.STRING), - Column(name="integer_column", column_type=ColumnType.INTEGER), - Column(name="float_column", column_type=ColumnType.DOUBLE), - ], - ) - table = table.store(synapse_client=self.syn) - self.schedule_for_cleanup(table.id) - - # AND data for the table stored in synapse - data_for_table = pd.DataFrame( - { - "column_string": ["value1", "value2", "value3", "value4"], - "integer_column": [1, 2, 3, None], - "float_column": [1.1, 2.2, 3.3, None], - } - ) - table.store_rows( - values=data_for_table, schema_storage_strategy=None, synapse_client=self.syn - ) - - # WHEN I query the table with a part mask - QUERY_RESULTS = 0x1 - QUERY_COUNT = 0x2 - SUM_FILE_SIZE_BYTES = 0x40 - LAST_UPDATED_ON = 0x80 - part_mask = QUERY_RESULTS | QUERY_COUNT | SUM_FILE_SIZE_BYTES | LAST_UPDATED_ON - - results = query_part_mask( - query=f"SELECT * FROM {table.id}", - synapse_client=self.syn, - part_mask=part_mask, - ) - - # THEN the data in the columns should match - pd.testing.assert_series_equal( - results.result["column_string"], data_for_table["column_string"] - ) - pd.testing.assert_series_equal( - results.result["integer_column"], data_for_table["integer_column"] - ) - pd.testing.assert_series_equal( - results.result["float_column"], data_for_table["float_column"] - ) - - # AND the part mask should be reflected in the results - assert results.count == 4 - assert results.sum_file_sizes is not None - assert results.sum_file_sizes.greater_than is not None - assert results.sum_file_sizes.sum_file_size_bytes is not None - assert results.last_updated_on is not None - - def test_part_mask_query_results_only(self, project_model: Project) -> None: - # GIVEN a table with a column defined - table_name = str(uuid.uuid4()) - table = Table( - name=table_name, - parent_id=project_model.id, - columns=[ - Column(name="column_string", column_type=ColumnType.STRING), - Column(name="integer_column", column_type=ColumnType.INTEGER), - Column(name="float_column", column_type=ColumnType.DOUBLE), - ], - ) - table = table.store(synapse_client=self.syn) - self.schedule_for_cleanup(table.id) - - # AND data for the table stored in synapse - data_for_table = pd.DataFrame( - { - "column_string": ["value1", "value2", "value3", "value4"], - "integer_column": [1, 2, 3, None], - "float_column": [1.1, 2.2, 3.3, None], - } - ) - table.store_rows( - values=data_for_table, schema_storage_strategy=None, synapse_client=self.syn - ) - - # WHEN I query the table with a part mask - QUERY_RESULTS = 0x1 - results = query_part_mask( - query=f"SELECT * FROM {table.id}", - synapse_client=self.syn, - part_mask=QUERY_RESULTS, - ) - - # THEN the data in the columns should match - pd.testing.assert_series_equal( - results.result["column_string"], data_for_table["column_string"] - ) - pd.testing.assert_series_equal( - results.result["integer_column"], data_for_table["integer_column"] - ) - pd.testing.assert_series_equal( - results.result["float_column"], data_for_table["float_column"] - ) - - # AND the part mask should be reflected in the results - assert results.count is None - assert results.sum_file_sizes is None - assert results.last_updated_on is None - - -class TestTableSnapshot: - @pytest.fixture(autouse=True, scope="function") - def init(self, syn: Synapse, schedule_for_cleanup: Callable[..., None]) -> None: - self.syn = syn - self.schedule_for_cleanup = schedule_for_cleanup - - def test_snapshot_basic(self, project_model: Project) -> None: - """Test creating a basic snapshot of a table.""" - # GIVEN a table with some data - table = Table( - name=str(uuid.uuid4()), - parent_id=project_model.id, - columns=[ - Column(name="col1", column_type=ColumnType.STRING), - Column(name="col2", column_type=ColumnType.INTEGER), - ], - ) - table = table.store(synapse_client=self.syn) - self.schedule_for_cleanup(table.id) - - # Store some data - data = {"col1": ["A", "B"], "col2": [1, 2]} - table.store_rows(values=data, synapse_client=self.syn) - - # WHEN I create a snapshot - snapshot_response = table.snapshot( - comment="Test snapshot", label="v1.0", synapse_client=self.syn - ) - - # THEN the snapshot should be created successfully - assert snapshot_response is not None - assert "snapshotVersionNumber" in snapshot_response - assert snapshot_response["snapshotVersionNumber"] is not None - - # AND the snapshot version should be 1 - snapshot_version = snapshot_response["snapshotVersionNumber"] - assert snapshot_version == 1 - - # AND when I retrieve the snapshot version, it should have the correct comment and label - snapshot_table = Table(id=table.id, version_number=snapshot_version).get( - synapse_client=self.syn - ) - assert snapshot_table.version_comment == "Test snapshot" - assert snapshot_table.version_label == "v1.0" - assert snapshot_table.version_number == 1 - - # AND when I retrieve the latest version (without specifying version), it should be "in progress" - latest_table = Table(id=table.id).get(synapse_client=self.syn) - assert latest_table.version_label == "in progress" - assert latest_table.version_comment == "in progress" - assert latest_table.version_number > 1 - - def test_snapshot_with_activity(self, project_model: Project) -> None: - """Test creating a snapshot with activity (provenance).""" - # GIVEN a table with some data and an activity - table = Table( - name=str(uuid.uuid4()), - parent_id=project_model.id, - columns=[ - Column(name="col1", column_type=ColumnType.STRING), - Column(name="col2", column_type=ColumnType.INTEGER), - ], - ) - table = table.store(synapse_client=self.syn) - self.schedule_for_cleanup(table.id) - - # Create and store an activity - activity = Activity( - name="Test Activity", - description="Test activity for snapshot", - ) - table.activity = activity - table.store(synapse_client=self.syn) - - # Store some data - data = {"col1": ["A", "B"], "col2": [1, 2]} - table.store_rows(values=data, synapse_client=self.syn) - - # WHEN I create a snapshot with activity included - snapshot_response = table.snapshot( - comment="Test snapshot with activity", - label="v1.0", - include_activity=True, - associate_activity_to_new_version=False, - synapse_client=self.syn, - ) - - # THEN the snapshot should be created successfully - assert snapshot_response is not None - assert "snapshotVersionNumber" in snapshot_response - assert snapshot_response["snapshotVersionNumber"] is not None - - # AND the snapshot version should be 1 - snapshot_version = snapshot_response["snapshotVersionNumber"] - assert snapshot_version == 1 - - # AND when I retrieve the snapshot version, it should have the correct comment and label - snapshot_table = Table(id=table.id, version_number=snapshot_version).get( - synapse_client=self.syn - ) - assert snapshot_table.version_comment == "Test snapshot with activity" - assert snapshot_table.version_label == "v1.0" - assert snapshot_table.version_number == 1 - - # AND when I retrieve the latest version (without specifying version), it should be "in progress" - latest_table = Table(id=table.id).get(synapse_client=self.syn) - assert latest_table.version_label == "in progress" - assert latest_table.version_comment == "in progress" - assert latest_table.version_number > 1 - - def test_snapshot_without_activity(self, project_model: Project) -> None: - """Test creating a snapshot without including activity.""" - # GIVEN a table with some data and an activity - table = Table( - name=str(uuid.uuid4()), - parent_id=project_model.id, - columns=[ - Column(name="col1", column_type=ColumnType.STRING), - Column(name="col2", column_type=ColumnType.INTEGER), - ], - ) - table = table.store(synapse_client=self.syn) - self.schedule_for_cleanup(table.id) - - # Create and store an activity - activity = Activity( - name="Test Activity", - description="Test activity for snapshot", - ) - table.activity = activity - table.store(synapse_client=self.syn) - - # Store some data - data = {"col1": ["A", "B"], "col2": [1, 2]} - table.store_rows(values=data, synapse_client=self.syn) - - # WHEN I create a snapshot without including activity - snapshot_response = table.snapshot( - comment="Test snapshot without activity", - label="v2.0", - include_activity=False, - synapse_client=self.syn, - ) - - # THEN the snapshot should be created successfully - assert snapshot_response is not None - assert "snapshotVersionNumber" in snapshot_response - assert snapshot_response["snapshotVersionNumber"] is not None - - # AND the snapshot version should be 1 - snapshot_version = snapshot_response["snapshotVersionNumber"] - assert snapshot_version == 1 - - # AND when I retrieve the snapshot version, it should have the correct comment and label - snapshot_table = Table(id=table.id, version_number=snapshot_version).get( - synapse_client=self.syn - ) - assert snapshot_table.version_comment == "Test snapshot without activity" - assert snapshot_table.version_label == "v2.0" - assert snapshot_table.version_number == 1 - - # AND when I retrieve the latest version (without specifying version), it should be "in progress" - latest_table = Table(id=table.id).get(synapse_client=self.syn) - assert latest_table.version_label == "in progress" - assert latest_table.version_comment == "in progress" - assert latest_table.version_number > 1 - - def test_snapshot_minimal_args(self, project_model: Project) -> None: - """Test creating a snapshot with minimal arguments.""" - # GIVEN a table with some data - table = Table( - name=str(uuid.uuid4()), - parent_id=project_model.id, - columns=[ - Column(name="col1", column_type=ColumnType.STRING), - Column(name="col2", column_type=ColumnType.INTEGER), - ], - ) - table = table.store(synapse_client=self.syn) - self.schedule_for_cleanup(table.id) - - # Store some data - data = {"col1": ["A", "B"], "col2": [1, 2]} - table.store_rows(values=data, synapse_client=self.syn) - - # WHEN I create a snapshot with minimal arguments - snapshot_response = table.snapshot(synapse_client=self.syn) - - # THEN the snapshot should be created successfully - assert snapshot_response is not None - assert "snapshotVersionNumber" in snapshot_response - assert snapshot_response["snapshotVersionNumber"] is not None - - # AND the snapshot version should be 1 - snapshot_version = snapshot_response["snapshotVersionNumber"] - assert snapshot_version == 1 - - # AND when I retrieve the snapshot version, it should have the correct version number - snapshot_table = Table(id=table.id, version_number=snapshot_version).get( - synapse_client=self.syn - ) - assert snapshot_table.version_number == 1 - # Comment and label should be None or empty when not specified - assert ( - snapshot_table.version_comment is None - or snapshot_table.version_comment == "" - ) - assert snapshot_table.version_label == "1" - - # AND when I retrieve the latest version (without specifying version), it should be "in progress" - latest_table = Table(id=table.id).get(synapse_client=self.syn) - assert latest_table.version_label == "in progress" - assert latest_table.version_comment == "in progress" - assert latest_table.version_number > 1 diff --git a/tests/integration/synapseclient/models/synchronous/test_team.py b/tests/integration/synapseclient/models/synchronous/test_team.py deleted file mode 100644 index 72e90b88c..000000000 --- a/tests/integration/synapseclient/models/synchronous/test_team.py +++ /dev/null @@ -1,189 +0,0 @@ -"""Integration tests for Team.""" - -import time -import uuid - -import pytest - -from synapseclient import Synapse -from synapseclient.core.exceptions import SynapseHTTPError -from synapseclient.models import Team -from synapseclient.models.user import UserGroupHeader - - -class TestTeam: - """Integration tests for Team.""" - - @pytest.fixture(autouse=True, scope="function") - def init(self, syn: Synapse) -> None: - self.syn = syn - self.expected_name = "test_team_" + str(uuid.uuid4()) - self.expected_description = "test description" - self.expected_icon = None - self.team = Team( - name=self.expected_name, - description=self.expected_description, - icon=self.expected_icon, - ) - self.TEST_USER = "DPETestUser2" - self.TEST_MESSAGE = "test message" - - def verify_team_properties(self, actual_team, expected_team): - """Helper to verify team properties match""" - assert actual_team.id == expected_team.id - assert actual_team.name == expected_team.name - assert actual_team.description == expected_team.description - assert actual_team.icon == expected_team.icon - assert actual_team.etag == expected_team.etag - assert actual_team.created_on == expected_team.created_on - assert actual_team.modified_on == expected_team.modified_on - assert actual_team.created_by == expected_team.created_by - assert actual_team.modified_by == expected_team.modified_by - - def test_team_lifecycle(self) -> None: - """Test create, retrieve (by ID, name), and delete operations""" - # GIVEN a team object - - # WHEN I create the team on Synapse - test_team = self.team.create(synapse_client=self.syn) - - # THEN I expect the created team to be returned with correct properties - assert test_team.id is not None - assert test_team.name == self.expected_name - assert test_team.description == self.expected_description - assert test_team.icon is None - assert test_team.can_public_join is False - assert test_team.can_request_membership is True - assert test_team.etag is not None - assert test_team.created_on is not None - assert test_team.modified_on is not None - assert test_team.created_by is not None - assert test_team.modified_by is not None - - # WHEN I retrieve the team using a Team object with ID - id_team = Team(id=test_team.id) - id_team = id_team.get(synapse_client=self.syn) - - # THEN the retrieved team should match the created team - self.verify_team_properties(id_team, test_team) - - # WHEN I retrieve the team using the static from_id method - from_id_team = Team.from_id(id=test_team.id, synapse_client=self.syn) - - # THEN the retrieved team should match the created team - self.verify_team_properties(from_id_team, test_team) - - # Name-based retrieval is eventually consistent, so we need to wait - time.sleep(10) - - # WHEN I retrieve the team using a Team object with name - name_team = Team(name=test_team.name) - name_team = name_team.get(synapse_client=self.syn) - - # THEN the retrieved team should match the created team - self.verify_team_properties(name_team, test_team) - - # WHEN I retrieve the team using the static from_name method - from_name_team = Team.from_name(name=test_team.name, synapse_client=self.syn) - - # THEN the retrieved team should match the created team - self.verify_team_properties(from_name_team, test_team) - - # WHEN I delete the team - test_team.delete(synapse_client=self.syn) - - # THEN the team should no longer exist - with pytest.raises( - SynapseHTTPError, - match=f"404 Client Error: Team id: '{test_team.id}' does not exist", - ): - Team.from_id(id=test_team.id, synapse_client=self.syn) - - def test_team_membership_and_invitations(self) -> None: - """Test team membership and invitation functionality""" - # GIVEN a team object - - # WHEN I create the team on Synapse - test_team = self.team.create(synapse_client=self.syn) - - # AND check the team members - members = test_team.members(synapse_client=self.syn) - - # THEN the team should have exactly one member (the creator), who is an admin - assert len(members) == 1 - assert members[0].team_id == test_team.id - assert isinstance(members[0].member, UserGroupHeader) - assert members[0].is_admin is True - - # WHEN I invite a user to the team - invite = test_team.invite( - user=self.TEST_USER, message=self.TEST_MESSAGE, synapse_client=self.syn - ) - - # THEN the invite should be created successfully - assert invite["id"] is not None - assert invite["teamId"] == str(test_team.id) - assert invite["inviteeId"] is not None - assert invite["message"] == self.TEST_MESSAGE - assert invite["createdOn"] is not None - assert invite["createdBy"] is not None - - # WHEN I check the open invitations - invitations = test_team.open_invitations(synapse_client=self.syn) - - # THEN I should see the invitation I just created - assert len(invitations) == 1 - assert invitations[0]["id"] is not None - assert invitations[0]["teamId"] == str(test_team.id) - assert invitations[0]["inviteeId"] is not None - assert invitations[0]["message"] == self.TEST_MESSAGE - assert invitations[0]["createdOn"] is not None - assert invitations[0]["createdBy"] is not None - - # Clean up - test_team.delete(synapse_client=self.syn) - - def test_team_membership_status(self) -> None: - """Test getting user membership status in a team""" - # GIVEN a team object - - # WHEN I create the team on Synapse - test_team = self.team.create(synapse_client=self.syn) - - # AND I get the membership status for the creator (who should be a member) - creator_status = test_team.get_user_membership_status( - user_id=self.syn.getUserProfile().ownerId, synapse_client=self.syn - ) - - # THEN the creator should have membership status indicating they are a member - assert creator_status.is_member is True - assert creator_status.team_id == str(test_team.id) - assert creator_status.has_open_invitation is False - assert creator_status.has_open_request is False - assert creator_status.can_join is True - assert creator_status.membership_approval_required is False - assert creator_status.has_unmet_access_requirement is False - assert creator_status.can_send_email is True - - # WHEN I invite a test user to the team - test_team.invite( - user=self.TEST_USER, - message=self.TEST_MESSAGE, - synapse_client=self.syn, - ) - # Check the invited user's status - invited_status = test_team.get_user_membership_status( - user_id=self.syn.getUserProfile(self.TEST_USER).ownerId, - synapse_client=self.syn, - ) - - # THEN the invited user should show they have an open invitation - assert invited_status is not None - assert invited_status.team_id == str(test_team.id) - assert invited_status.has_open_invitation is True - assert invited_status.membership_approval_required is True - assert invited_status.can_send_email is True - assert invited_status.is_member is False - - # Clean up - test_team.delete(synapse_client=self.syn) diff --git a/tests/integration/synapseclient/models/synchronous/test_user.py b/tests/integration/synapseclient/models/synchronous/test_user.py deleted file mode 100644 index d7987fc11..000000000 --- a/tests/integration/synapseclient/models/synchronous/test_user.py +++ /dev/null @@ -1,67 +0,0 @@ -"""Integration tests for UserProfile.""" - -from typing import Callable - -import pytest - -from synapseclient import Synapse -from synapseclient.models import UserProfile - - -class TestUser: - """Integration tests for UserProfile.""" - - @pytest.fixture(autouse=True, scope="function") - def init(self, syn: Synapse, schedule_for_cleanup: Callable[..., None]) -> None: - self.syn = syn - self.schedule_for_cleanup = schedule_for_cleanup - - def test_from_id(self) -> None: - # GIVEN our test profile - integration_test_profile = UserProfile().get(synapse_client=self.syn) - - # WHEN we get the profile by ID - profile = UserProfile.from_id( - integration_test_profile.id, synapse_client=self.syn - ) - - # THEN we expect the profile to be the same as the one we got from the fixture - assert profile == integration_test_profile - - def test_from_username(self) -> None: - # GIVEN our test profile - integration_test_profile = UserProfile().get(synapse_client=self.syn) - - # WHEN we get the profile by username - profile = UserProfile.from_username( - integration_test_profile.username, synapse_client=self.syn - ) - - # THEN we expect the profile to be the same as the one we got from the fixture - assert profile == integration_test_profile - - def test_is_certified_id(self) -> None: - # GIVEN out test profile - integration_test_profile = UserProfile().get(synapse_client=self.syn) - - # AND a copy of the profile - profile_copy = UserProfile(id=integration_test_profile.id) - - # WHEN we check if the profile is certified - is_certified = profile_copy.is_certified(synapse_client=self.syn) - - # THEN we expect the profile to not be certified - assert is_certified is False - - def test_is_certified_username(self) -> None: - # GIVEN out test profile - integration_test_profile = UserProfile().get(synapse_client=self.syn) - - # AND a copy of the profile - profile_copy = UserProfile(username=integration_test_profile.username) - - # WHEN we check if the profile is certified - is_certified = profile_copy.is_certified(synapse_client=self.syn) - - # THEN we expect the profile to not be certified - assert is_certified is False diff --git a/tests/integration/synapseclient/models/synchronous/test_virtualtable.py b/tests/integration/synapseclient/models/synchronous/test_virtualtable.py deleted file mode 100644 index fe0eb1f2b..000000000 --- a/tests/integration/synapseclient/models/synchronous/test_virtualtable.py +++ /dev/null @@ -1,449 +0,0 @@ -import time -import uuid -from typing import Callable - -import pandas as pd -import pytest - -from synapseclient import Synapse -from synapseclient.core.exceptions import SynapseHTTPError -from synapseclient.models import Column, ColumnType, Project, Table, VirtualTable - - -class TestVirtualTableCreationAndManagement: - @pytest.fixture(autouse=True, scope="function") - def init(self, syn: Synapse, schedule_for_cleanup: Callable[..., None]) -> None: - self.syn = syn - self.schedule_for_cleanup = schedule_for_cleanup - - @pytest.fixture(scope="function") - def base_table_with_columns(self, project_model: Project) -> Table: - # Create a table with columns for testing - table_name = str(uuid.uuid4()) - table = Table( - name=table_name, - parent_id=project_model.id, - columns=[ - Column(name="test_column", column_type=ColumnType.STRING), - Column(name="test_column2", column_type=ColumnType.INTEGER), - ], - ) - table = table.store(synapse_client=self.syn) - self.schedule_for_cleanup(table.id) - return table - - def test_virtual_table_validation_scenarios(self, project_model: Project) -> None: - # GIVEN different virtual table scenarios with validation issues - - # Test case 1: Empty defining SQL - empty_sql_virtual_table = VirtualTable( - name=str(uuid.uuid4()), - description="Test virtual table", - parent_id=project_model.id, - defining_sql="", - ) - - # WHEN/THEN empty SQL should be rejected - with pytest.raises( - ValueError, - match="The defining_sql attribute must be set for a", - ): - empty_sql_virtual_table.store(synapse_client=self.syn) - - # Test case 2: Table with no columns - table_name = str(uuid.uuid4()) - empty_column_table = Table( - name=table_name, - parent_id=project_model.id, - ) - empty_column_table = empty_column_table.store(synapse_client=self.syn) - self.schedule_for_cleanup(empty_column_table.id) - - # WHEN/THEN using a table with empty schema should be rejected - with pytest.raises( - SynapseHTTPError, - match=f"400 Client Error: Schema for {empty_column_table.id} is empty.", - ): - VirtualTable( - name=str(uuid.uuid4()), - parent_id=project_model.id, - description="Test virtual table", - defining_sql=f"SELECT * FROM {empty_column_table.id}", - ).store(synapse_client=self.syn) - - # Test case 3: Invalid SQL syntax - invalid_sql_virtual_table = VirtualTable( - name=str(uuid.uuid4()), - parent_id=project_model.id, - description="Test virtual table", - defining_sql="INVALID SQL", - ) - - # WHEN/THEN invalid SQL should be rejected - with pytest.raises( - SynapseHTTPError, - match='400 Client Error: Encountered " "INVALID "" ' - "at line 1, column 1.\nWas expecting one of:", - ): - invalid_sql_virtual_table.store(synapse_client=self.syn) - - def test_virtual_table_lifecycle(self, base_table_with_columns: Table) -> None: - # GIVEN a table with columns and a new virtual table - table = base_table_with_columns - virtual_table_name = str(uuid.uuid4()) - virtual_table_description = "Test virtual table" - virtual_table = VirtualTable( - name=virtual_table_name, - parent_id=table.parent_id, - description=virtual_table_description, - defining_sql=f"SELECT * FROM {table.id}", - ) - - # WHEN creating the virtual table - virtual_table = virtual_table.store(synapse_client=self.syn) - self.schedule_for_cleanup(virtual_table.id) - - # THEN the virtual table should be created successfully - assert virtual_table.id is not None - - # WHEN retrieving the virtual table - retrieved_virtual_table = VirtualTable(id=virtual_table.id).get( - synapse_client=self.syn - ) - - # THEN the retrieved virtual table should match the created one - assert retrieved_virtual_table is not None - assert retrieved_virtual_table.name == virtual_table_name - assert retrieved_virtual_table.id == virtual_table.id - assert retrieved_virtual_table.description == virtual_table_description - - # WHEN updating the virtual table attributes - updated_name = str(uuid.uuid4()) - updated_description = "Updated description" - updated_sql = f"SELECT test_column FROM {table.id}" - - retrieved_virtual_table.name = updated_name - retrieved_virtual_table.description = updated_description - retrieved_virtual_table.defining_sql = updated_sql - retrieved_virtual_table.store(synapse_client=self.syn) - - # THEN the updates should be reflected when retrieving again - latest_virtual_table = VirtualTable(id=virtual_table.id).get( - synapse_client=self.syn - ) - assert latest_virtual_table.name == updated_name - assert latest_virtual_table.description == updated_description - assert latest_virtual_table.defining_sql == updated_sql - assert latest_virtual_table.id == virtual_table.id # ID should remain the same - - # WHEN deleting the virtual table - virtual_table.delete(synapse_client=self.syn) - - # THEN the virtual table should be deleted - with pytest.raises( - SynapseHTTPError, - match=f"404 Client Error: Entity {virtual_table.id} is in trash can.", - ): - VirtualTable(id=virtual_table.id).get(synapse_client=self.syn) - - -class TestVirtualTableWithDataOperations: - @pytest.fixture(autouse=True, scope="function") - def init(self, syn: Synapse, schedule_for_cleanup: Callable[..., None]) -> None: - self.syn = syn - self.schedule_for_cleanup = schedule_for_cleanup - - @pytest.fixture(scope="function") - def base_table_with_data(self, project_model: Project) -> Table: - # Create a table with columns and data - table_name = str(uuid.uuid4()) - table = Table( - name=table_name, - parent_id=project_model.id, - columns=[ - Column(name="name", column_type=ColumnType.STRING), - Column(name="age", column_type=ColumnType.INTEGER), - Column(name="city", column_type=ColumnType.STRING), - ], - ) - table = table.store(synapse_client=self.syn) - self.schedule_for_cleanup(table.id) - - # Insert data into the table - data = pd.DataFrame( - { - "name": ["Alice", "Bob", "Charlie"], - "age": [30, 25, 35], - "city": ["New York", "Boston", "Chicago"], - } - ) - table.store_rows(data, synapse_client=self.syn) - - return table - - def test_virtual_table_data_queries( - self, project_model: Project, base_table_with_data: Table - ) -> None: - # GIVEN a table with data and various virtual tables with different SQL transformations - table = base_table_with_data - - # Test case 1: Basic selection of all data - virtual_table_all = VirtualTable( - name=str(uuid.uuid4()), - parent_id=project_model.id, - defining_sql=f"SELECT * FROM {table.id}", - ) - virtual_table_all = virtual_table_all.store(synapse_client=self.syn) - self.schedule_for_cleanup(virtual_table_all.id) - - # Test case 2: Column selection - virtual_table_columns = VirtualTable( - name=str(uuid.uuid4()), - parent_id=project_model.id, - defining_sql=f"SELECT name, city FROM {table.id}", - ) - virtual_table_columns = virtual_table_columns.store(synapse_client=self.syn) - self.schedule_for_cleanup(virtual_table_columns.id) - - # Test case 3: Filtering - virtual_table_filtered = VirtualTable( - name=str(uuid.uuid4()), - parent_id=project_model.id, - defining_sql=f"SELECT * FROM {table.id} WHERE age > 25", - ) - virtual_table_filtered = virtual_table_filtered.store(synapse_client=self.syn) - self.schedule_for_cleanup(virtual_table_filtered.id) - - # Test case 4: Ordering - virtual_table_ordered = VirtualTable( - name=str(uuid.uuid4()), - parent_id=project_model.id, - defining_sql=f"SELECT * FROM {table.id} ORDER BY age DESC", - ) - virtual_table_ordered = virtual_table_ordered.store(synapse_client=self.syn) - self.schedule_for_cleanup(virtual_table_ordered.id) - - # Wait for the virtual tables to be ready - time.sleep(2) - - # WHEN querying the full-data virtual table - all_result = virtual_table_all.query( - f"SELECT * FROM {virtual_table_all.id}", synapse_client=self.syn - ) - - # THEN all data should be returned - assert len(all_result) == 3 - assert set(all_result["name"].tolist()) == {"Alice", "Bob", "Charlie"} - assert set(all_result["age"].tolist()) == {30, 25, 35} - assert set(all_result["city"].tolist()) == {"New York", "Boston", "Chicago"} - - # WHEN querying the column-selection virtual table - columns_result = virtual_table_columns.query( - f"SELECT * FROM {virtual_table_columns.id}", synapse_client=self.syn - ) - - # THEN only specified columns should be returned - assert len(columns_result) == 3 - assert "name" in columns_result.columns - assert "city" in columns_result.columns - assert "age" not in columns_result.columns - assert set(columns_result["name"].tolist()) == {"Alice", "Bob", "Charlie"} - assert set(columns_result["city"].tolist()) == {"New York", "Boston", "Chicago"} - - # WHEN querying the filtered virtual table - filtered_result = virtual_table_filtered.query( - f"SELECT * FROM {virtual_table_filtered.id}", synapse_client=self.syn - ) - - # THEN only filtered rows should be returned - assert len(filtered_result) == 2 - assert set(filtered_result["name"].tolist()) == {"Alice", "Charlie"} - assert set(filtered_result["age"].tolist()) == {30, 35} - - # WHEN querying the ordered virtual table - ordered_result = virtual_table_ordered.query( - f"SELECT * FROM {virtual_table_ordered.id}", synapse_client=self.syn - ) - - # THEN data should be in the specified order - assert len(ordered_result) == 3 - assert ordered_result["age"].tolist() == [35, 30, 25] - assert ordered_result["name"].tolist() == ["Charlie", "Alice", "Bob"] - - def test_virtual_table_data_synchronization(self, project_model: Project) -> None: - # GIVEN a table with columns but no initial data - table_name = str(uuid.uuid4()) - table = Table( - name=table_name, - parent_id=project_model.id, - columns=[ - Column(name="name", column_type=ColumnType.STRING), - Column(name="age", column_type=ColumnType.INTEGER), - ], - ) - table = table.store(synapse_client=self.syn) - self.schedule_for_cleanup(table.id) - - # AND a virtual table based on that table - virtual_table = VirtualTable( - name=str(uuid.uuid4()), - parent_id=project_model.id, - defining_sql=f"SELECT * FROM {table.id}", - ) - virtual_table = virtual_table.store(synapse_client=self.syn) - self.schedule_for_cleanup(virtual_table.id) - - # Wait for the virtual table to be ready - time.sleep(2) - - # WHEN querying the virtual table with empty source table - empty_result = virtual_table.query( - f"SELECT * FROM {virtual_table.id}", synapse_client=self.syn - ) - - # THEN no data should be returned - assert len(empty_result) == 0 - - # WHEN adding data to the source table - data = pd.DataFrame({"name": ["Alice", "Bob"], "age": [30, 25]}) - table.store_rows(data, synapse_client=self.syn) - - # Wait for the updates to propagate - time.sleep(2) - - # AND querying the virtual table again - added_data_result = virtual_table.query( - f"SELECT * FROM {virtual_table.id}", synapse_client=self.syn - ) - - # THEN the virtual table should reflect the new data - assert len(added_data_result) == 2 - assert added_data_result["name"].tolist() == ["Alice", "Bob"] - assert added_data_result["age"].tolist() == [30, 25] - - # WHEN removing data from the source table - table.delete_rows( - query=f"SELECT ROW_ID, ROW_VERSION FROM {table.id}", synapse_client=self.syn - ) - - # Wait for changes to propagate - time.sleep(2) - - # AND querying the virtual table again - removed_data_result = virtual_table.query( - f"SELECT * FROM {virtual_table.id}", synapse_client=self.syn - ) - - # THEN the virtual table should reflect the removed data - assert len(removed_data_result) == 0 - - def test_virtual_table_sql_updates( - self, project_model: Project, base_table_with_data: Table - ) -> None: - # GIVEN a table with data and a virtual table with initial SQL - table = base_table_with_data - virtual_table = VirtualTable( - name=str(uuid.uuid4()), - parent_id=project_model.id, - defining_sql=f"SELECT * FROM {table.id}", - ) - virtual_table = virtual_table.store(synapse_client=self.syn) - self.schedule_for_cleanup(virtual_table.id) - - # Wait for the virtual table to be ready - time.sleep(2) - - # WHEN querying the virtual table with initial SQL - initial_result = virtual_table.query( - f"SELECT * FROM {virtual_table.id}", synapse_client=self.syn - ) - - # THEN all columns should be present - assert len(initial_result) == 3 - assert "name" in initial_result.columns - assert "age" in initial_result.columns - assert "city" in initial_result.columns - - # WHEN updating the defining SQL to select fewer columns - virtual_table.defining_sql = f"SELECT name, city FROM {table.id}" - virtual_table = virtual_table.store(synapse_client=self.syn) - - # Wait for the update to propagate - time.sleep(2) - - # AND querying the virtual table again - updated_result = virtual_table.query( - f"SELECT * FROM {virtual_table.id}", synapse_client=self.syn - ) - - # THEN the result should reflect the SQL change - assert len(updated_result) == 3 - assert "name" in updated_result.columns - assert "city" in updated_result.columns - assert "age" not in updated_result.columns - - # AND the schema should be updated when retrieving the virtual table - retrieved_virtual_table = VirtualTable(id=virtual_table.id).get( - synapse_client=self.syn - ) - assert "name" in retrieved_virtual_table.columns.keys() - assert "city" in retrieved_virtual_table.columns.keys() - assert "age" not in retrieved_virtual_table.columns.keys() - - def test_virtual_table_with_aggregation(self, project_model: Project) -> None: - # GIVEN a table with data suitable for aggregation - table_name = str(uuid.uuid4()) - table = Table( - name=table_name, - parent_id=project_model.id, - columns=[ - Column(name="department", column_type=ColumnType.STRING), - Column(name="salary", column_type=ColumnType.INTEGER), - ], - ) - table = table.store(synapse_client=self.syn) - self.schedule_for_cleanup(table.id) - - # Insert data into the table - data = pd.DataFrame( - { - "department": ["IT", "HR", "IT", "Finance", "HR"], - "salary": [70000, 60000, 80000, 90000, 65000], - } - ) - table.store_rows(data, synapse_client=self.syn) - - # AND a virtual table with aggregation SQL - defining_sql = f"""SELECT - department, - COUNT(*) as employee_count - FROM {table.id} - GROUP BY department""" - - virtual_table = VirtualTable( - name=str(uuid.uuid4()), - parent_id=project_model.id, - defining_sql=defining_sql, - ) - virtual_table = virtual_table.store(synapse_client=self.syn) - self.schedule_for_cleanup(virtual_table.id) - - # Wait for virtual table to be ready - time.sleep(3) - - # WHEN querying the aggregation virtual table - query_result = virtual_table.query( - f"SELECT * FROM {virtual_table.id}", synapse_client=self.syn - ) - - # THEN the result should contain the aggregated data - assert len(query_result) == 3 - - # Sort the result for consistent testing - query_result = query_result.sort_values("department").reset_index(drop=True) - - # Verify departments - assert query_result["department"].tolist() == ["Finance", "HR", "IT"] - - # Verify counts - assert query_result["employee_count"].tolist() == [1, 2, 2] diff --git a/tests/integration/synapseclient/models/synchronous/test_wiki.py b/tests/integration/synapseclient/models/synchronous/test_wiki.py deleted file mode 100644 index 6636446b5..000000000 --- a/tests/integration/synapseclient/models/synchronous/test_wiki.py +++ /dev/null @@ -1,759 +0,0 @@ -"""Integration tests for the synapseclient.models.wiki module.""" - -import gzip -import os -import tempfile -import time -import uuid -from typing import Callable - -import pytest - -from synapseclient import Synapse -from synapseclient.core import utils -from synapseclient.core.exceptions import SynapseHTTPError -from synapseclient.models import ( - Project, - WikiHeader, - WikiHistorySnapshot, - WikiOrderHint, - WikiPage, -) - - -@pytest.fixture(scope="function") -def wiki_page_fixture( - syn: Synapse, schedule_for_cleanup: Callable[..., None] -) -> WikiPage: - """Create a root wiki page fixture that can be shared across tests.""" - # Create a new project for this test class - project = Project(name=f"Test Wiki Project_" + str(uuid.uuid4())) - project = project.store(synapse_client=syn) - schedule_for_cleanup(project.id) - - wiki_title = f"Root Wiki Page {str(uuid.uuid4())}" - wiki_markdown = "# Root Wiki Page\n\nThis is a root wiki page." - - wiki_page = WikiPage( - owner_id=project.id, - title=wiki_title, - markdown=wiki_markdown, - ) - root_wiki = wiki_page.store(synapse_client=syn) - schedule_for_cleanup(root_wiki.id) - return root_wiki - - -class TestWikiPageBasicOperations: - """Tests for basic WikiPage CRUD operations.""" - - @pytest.fixture(autouse=True, scope="function") - def init(self, syn: Synapse, schedule_for_cleanup: Callable[..., None]) -> None: - self.syn = syn - self.schedule_for_cleanup = schedule_for_cleanup - - def test_get_wiki_page_by_id( - self, - wiki_page_fixture: WikiPage, - schedule_for_cleanup: Callable[..., None], - ) -> None: - """Test getting a wiki page by ID.""" - # GIVEN an existing wiki page (from fixture) - root_wiki = wiki_page_fixture - - # WHEN retrieving the wiki page by ID - retrieved_wiki = WikiPage(owner_id=root_wiki.owner_id, id=root_wiki.id).get( - synapse_client=self.syn - ) - schedule_for_cleanup(retrieved_wiki.id) - - # THEN the retrieved wiki should match the created one - assert retrieved_wiki.id == root_wiki.id - assert retrieved_wiki.title == root_wiki.title - assert retrieved_wiki.owner_id == root_wiki.owner_id - - def test_get_wiki_page_by_title( - self, - wiki_page_fixture: WikiPage, - schedule_for_cleanup: Callable[..., None], - ) -> None: - """Test getting a wiki page by title.""" - # GIVEN an existing wiki page (from fixture) - root_wiki = wiki_page_fixture - - # WHEN retrieving the wiki page by title - retrieved_wiki = WikiPage( - owner_id=root_wiki.owner_id, title=root_wiki.title - ).get(synapse_client=self.syn) - schedule_for_cleanup(retrieved_wiki.id) - # THEN the retrieved wiki should match the created one - assert retrieved_wiki.id == root_wiki.id - assert retrieved_wiki.title == root_wiki.title - assert retrieved_wiki.owner_id == root_wiki.owner_id - - def test_delete_wiki_page( - self, - wiki_page_fixture: WikiPage, - schedule_for_cleanup: Callable[..., None], - ) -> None: - # GIVEN an existing wiki page (from fixture) - root_wiki = wiki_page_fixture - - # WHEN creating a wiki page to delete - wiki_page_to_delete = WikiPage( - owner_id=root_wiki.owner_id, - parent_id=root_wiki.id, - title=f"Wiki Page to be deleted {str(uuid.uuid4())}", - markdown="# Wiki Page to be deleted\n\nThis is a wiki page to be deleted.", - ).store(synapse_client=self.syn) - schedule_for_cleanup(wiki_page_to_delete.id) - # WHEN deleting the wiki page - wiki_page_to_delete.delete(synapse_client=self.syn) - - # THEN the wiki page should be deleted - with pytest.raises(SynapseHTTPError, match="404"): - WikiPage(owner_id=root_wiki.owner_id, id=wiki_page_to_delete.id).get( - synapse_client=self.syn - ) - - def test_create_sub_wiki_page( - self, - wiki_page_fixture: WikiPage, - schedule_for_cleanup: Callable[..., None], - ) -> None: - """Test creating a sub-wiki page under a root wiki page.""" - # GIVEN a root wiki page - root_wiki = wiki_page_fixture - - # WHEN creating a sub-wiki page - title = f"Sub Wiki Basic Operations {str(uuid.uuid4())}" - sub_wiki = WikiPage( - owner_id=root_wiki.owner_id, - parent_id=root_wiki.id, - title=title, - markdown="# Sub Wiki Basic Operations\n\nThis is a sub wiki basic operations page.", - ).store(synapse_client=self.syn) - schedule_for_cleanup(sub_wiki.id) - # THEN the sub-wiki page should be created - assert sub_wiki.id is not None - assert sub_wiki.title == title - assert sub_wiki.parent_id == root_wiki.id - assert sub_wiki.owner_id == root_wiki.owner_id - - -class TestWikiPageAttachments: - """Tests for WikiPage attachment operations.""" - - @pytest.fixture(autouse=True, scope="function") - def init(self, syn: Synapse, schedule_for_cleanup: Callable[..., None]) -> None: - self.syn = syn - self.schedule_for_cleanup = schedule_for_cleanup - - @pytest.fixture(scope="function") - def wiki_page_with_attachment( - self, - syn: Synapse, - schedule_for_cleanup: Callable[..., None], - wiki_page_fixture: WikiPage, - ) -> tuple[WikiPage, str]: - """Create a wiki page with an attachment.""" - # Create a temporary file for attachment - filename = utils.make_bogus_uuid_file() - schedule_for_cleanup(filename) - # GIVEN a root wiki page - root_wiki = wiki_page_fixture - # Create wiki page with attachment - wiki_page = WikiPage( - owner_id=root_wiki.owner_id, - title=f"Sub Wiki with Attachment {str(uuid.uuid4())}", - markdown="# Sub Wiki with Attachment\n\nThis is a sub wiki with an attachment page.", - parent_id=root_wiki.id, - attachments=[filename], - ) - wiki_page = wiki_page.store(synapse_client=syn) - schedule_for_cleanup(wiki_page.id) - attachment_name = os.path.basename(filename) - return wiki_page, attachment_name - - def test_get_attachment_handles( - self, - wiki_page_with_attachment: tuple[WikiPage, str], - schedule_for_cleanup: Callable[..., None], - ) -> None: - # GIVEN a wiki page with an attachment - wiki_page, attachment_name = wiki_page_with_attachment - - # WHEN getting attachment handles - attachment_handles = wiki_page.get_attachment_handles(synapse_client=self.syn) - - # THEN attachment handles should be returned - assert len(attachment_handles["list"]) > 0 - schedule_for_cleanup(attachment_handles) - - def test_get_attachment_url( - self, - wiki_page_with_attachment: tuple[WikiPage, str], - schedule_for_cleanup: Callable[..., None], - ) -> None: - # GIVEN a wiki page with an attachment - wiki_page, attachment_name = wiki_page_with_attachment - - # WHEN getting attachment URL - attachment_url = wiki_page.get_attachment( - file_name=attachment_name, download_file=False, synapse_client=self.syn - ) - - # THEN a URL should be returned - assert len(attachment_url) > 0 - schedule_for_cleanup(attachment_url) - - def test_download_attachment( - self, - wiki_page_with_attachment: tuple[WikiPage, str], - schedule_for_cleanup: Callable[..., None], - ) -> None: - # GIVEN a wiki page with an attachment - wiki_page, attachment_name = wiki_page_with_attachment - - # AND a download location - download_dir = tempfile.mkdtemp() - self.schedule_for_cleanup(download_dir) - - # WHEN downloading the attachment - downloaded_path = wiki_page.get_attachment( - file_name=attachment_name, - download_file=True, - download_location=download_dir, - synapse_client=self.syn, - ) - schedule_for_cleanup(downloaded_path) - # THEN the file should be downloaded - assert os.path.exists(downloaded_path) - - def test_get_attachment_preview_url( - self, - wiki_page_with_attachment: tuple[WikiPage, str], - schedule_for_cleanup: Callable[..., None], - ) -> None: - # GIVEN a wiki page with an attachment - wiki_page, attachment_name = wiki_page_with_attachment - # Sleep for 0.5 minutes to ensure the attachment preview is created - time.sleep(0.5 * 60) - # WHEN getting attachment preview URL - preview_url = wiki_page.get_attachment_preview( - file_name=attachment_name, download_file=False, synapse_client=self.syn - ) - - # THEN a URL should be returned - assert len(preview_url) > 0 - schedule_for_cleanup(preview_url) - - def test_download_attachment_preview( - self, - wiki_page_with_attachment: tuple[WikiPage, str], - schedule_for_cleanup: Callable[..., None], - ) -> None: - # GIVEN a wiki page with an attachment - wiki_page, attachment_name = wiki_page_with_attachment - - # AND a download location - download_dir = tempfile.mkdtemp() - self.schedule_for_cleanup(download_dir) - - time.sleep(15) - - # WHEN downloading the attachment preview - downloaded_path = wiki_page.get_attachment_preview( - file_name=attachment_name, - download_file=True, - download_location=download_dir, - synapse_client=self.syn, - ) - schedule_for_cleanup(downloaded_path) - # THEN the file should be downloadeds - assert os.path.exists(downloaded_path) - assert os.path.basename(downloaded_path) == "preview.txt" - - @pytest.mark.skipif( - os.getenv("GITHUB_ACTIONS") == "true", - reason="This test runs only locally, not in CI/CD environments.", - ) - def test_download_attachment_large_file( - self, - syn: Synapse, - schedule_for_cleanup: Callable[..., None], - wiki_page_fixture: WikiPage, - ) -> None: - """Test downloading a large attachment file (> 8 MiB) - local only.""" - # GIVEN a wiki page with an attachment - root_wiki = wiki_page_fixture - # Create a temporary file for attachment with > 8 MiB - filename = utils.make_bogus_uuid_file() - with open(filename, "wb") as f: - f.write(b"\0" * (9 * 1024 * 1024)) - - # AND a download location - download_dir = tempfile.mkdtemp() - schedule_for_cleanup(download_dir) - - # Create wiki page with attachment - wiki_page = WikiPage( - owner_id=root_wiki.owner_id, - title=f"Sub Wiki with large Attachment {str(uuid.uuid4())}", - markdown="# Sub Wiki with large Attachment\n\nThis is a sub wiki with a large attachment page.", - parent_id=root_wiki.id, - attachments=[filename], - ) - wiki_page = wiki_page.store(synapse_client=self.syn) - schedule_for_cleanup(wiki_page.id) - # WHEN downloading the attachment - downloaded_path = wiki_page.get_attachment( - file_name=os.path.basename(filename), - download_file=True, - download_location=download_dir, - synapse_client=self.syn, - ) - schedule_for_cleanup(downloaded_path) - # THEN the file should be downloaded - assert os.path.exists(downloaded_path) - assert os.path.basename(downloaded_path) == os.path.basename(filename) - - @pytest.fixture(scope="function") - def wiki_page_with_gz_attachment( - self, - syn: Synapse, - schedule_for_cleanup: Callable[..., None], - wiki_page_fixture: WikiPage, - ) -> tuple[WikiPage, str]: - """Create a wiki page with a gzipped attachment.""" - # Create a gzipped file - filename = utils.make_bogus_uuid_file() - # Rename to add .gz extension - gz_filename = filename + ".gz" - os.rename(filename, gz_filename) - with gzip.open(gz_filename, "wt") as f: - f.write("hello world\n") - schedule_for_cleanup(gz_filename) - - # GIVEN a root wiki page - root_wiki = wiki_page_fixture - # Create wiki page with gz attachment - wiki_page = WikiPage( - owner_id=root_wiki.owner_id, - title=f"Sub Wiki with GZ Attachment {str(uuid.uuid4())}", - markdown="# Sub Wiki with GZ Attachment\n\nThis is a sub wiki with a gz attachment page.", - parent_id=root_wiki.id, - attachments=[gz_filename], - ) - sub_wiki = wiki_page.store(synapse_client=syn) - schedule_for_cleanup(sub_wiki.id) - attachment_name = os.path.basename(gz_filename) - return sub_wiki, attachment_name - - def test_get_attachment_handles_gz_file( - self, - wiki_page_with_gz_attachment: tuple[WikiPage, str], - schedule_for_cleanup: Callable[..., None], - ) -> None: - """Test getting attachment handles for a gz file.""" - # GIVEN a wiki page with a gz attachment - wiki_page, attachment_name = wiki_page_with_gz_attachment - # WHEN getting attachment handles - attachment_handles = wiki_page.get_attachment_handles(synapse_client=self.syn) - - # THEN attachment handles should be returned - assert len(attachment_handles["list"]) > 0 - # Verify the attachment name contains .gz - assert any( - handle.get("fileName", "").endswith(".gz") - for handle in attachment_handles["list"] - ) - schedule_for_cleanup(attachment_handles) - - def test_get_attachment_url_gz_file( - self, - wiki_page_with_gz_attachment: tuple[WikiPage, str], - schedule_for_cleanup: Callable[..., None], - ) -> None: - """Test getting attachment URL for a gz file.""" - # GIVEN a wiki page with a gz attachment - wiki_page, attachment_name = wiki_page_with_gz_attachment - - # WHEN getting attachment URL - attachment_url = wiki_page.get_attachment( - file_name=attachment_name, download_file=False, synapse_client=self.syn - ) - # THEN a URL should be returned - assert len(attachment_url) > 0 - schedule_for_cleanup(attachment_url) - - def test_download_attachment_gz_file( - self, - wiki_page_with_gz_attachment: tuple[WikiPage, str], - schedule_for_cleanup: Callable[..., None], - ) -> None: - """Test downloading a gz attachment file.""" - # GIVEN a wiki page with a gz attachment - wiki_page, attachment_name = wiki_page_with_gz_attachment - - # AND a download location - download_dir = tempfile.mkdtemp() - schedule_for_cleanup(download_dir) - - # WHEN downloading the gz attachment - downloaded_path = wiki_page.get_attachment( - file_name=attachment_name, - download_file=True, - download_location=download_dir, - synapse_client=self.syn, - ) - schedule_for_cleanup(downloaded_path) - - # THEN the file should be downloaded - assert os.path.exists(downloaded_path) - assert os.path.basename(downloaded_path) + ".gz" == attachment_name - - def test_get_attachment_preview_url_gz_file( - self, - wiki_page_with_gz_attachment: tuple[WikiPage, str], - schedule_for_cleanup: Callable[..., None], - ) -> None: - """Test getting attachment preview URL for a gz file.""" - # GIVEN a wiki page with a gz attachment - wiki_page, attachment_name = wiki_page_with_gz_attachment - # Sleep for 0.5 minutes to ensure the attachment preview is created - time.sleep(0.5 * 60) - # WHEN getting attachment preview URL - preview_url = wiki_page.get_attachment_preview( - file_name=attachment_name, download_file=False, synapse_client=self.syn - ) - - # THEN a URL should be returned - assert len(preview_url) > 0 - schedule_for_cleanup(preview_url) - - def test_download_attachment_preview_gz_file( - self, - wiki_page_with_gz_attachment: tuple[WikiPage, str], - schedule_for_cleanup: Callable[..., None], - ) -> None: - """Test downloading attachment preview for a gz file.""" - # GIVEN a wiki page with a gz attachment - wiki_page, attachment_name = wiki_page_with_gz_attachment - - # AND a download location - download_dir = tempfile.mkdtemp() - self.schedule_for_cleanup(download_dir) - - time.sleep(15) - - # WHEN downloading the attachment preview - downloaded_path = wiki_page.get_attachment_preview( - file_name=attachment_name, - download_file=True, - download_location=download_dir, - synapse_client=self.syn, - ) - schedule_for_cleanup(downloaded_path) - - # THEN the file should be downloaded - assert os.path.exists(downloaded_path) - assert os.path.basename(downloaded_path) == "preview.txt" - - -class TestWikiPageMarkdown: - """Tests for WikiPage markdown operations.""" - - @pytest.fixture(autouse=True, scope="function") - def init(self, syn: Synapse, schedule_for_cleanup: Callable[..., None]) -> None: - self.syn = syn - self.schedule_for_cleanup = schedule_for_cleanup - - @pytest.fixture(scope="function") - def wiki_page_with_markdown( - self, - syn: Synapse, - schedule_for_cleanup: Callable[..., None], - wiki_page_fixture: WikiPage, - ) -> WikiPage: - # GIVEN a wiki page with markdown - root_wiki = wiki_page_fixture - wiki_page = WikiPage( - owner_id=root_wiki.owner_id, - title=f"Sub Wiki Markdown {str(uuid.uuid4())}", - markdown="# Sub Wiki Markdown\n\nThis is a sub wiki markdown page.", - parent_id=root_wiki.id, - ) - sub_wiki = wiki_page.store(synapse_client=syn) - schedule_for_cleanup(sub_wiki.id) - return sub_wiki - - def test_get_markdown_url( - self, - wiki_page_with_markdown: WikiPage, - schedule_for_cleanup: Callable[..., None], - ) -> None: - # GIVEN a wiki page with markdown - root_wiki = wiki_page_with_markdown - - # WHEN getting markdown URL - markdown_url = WikiPage( - owner_id=root_wiki.owner_id, id=root_wiki.id - ).get_markdown_file(download_file=False, synapse_client=self.syn) - schedule_for_cleanup(markdown_url) - # THEN a URL should be returned - assert len(markdown_url) > 0 - - def test_download_markdown_file( - self, - wiki_page_with_markdown: WikiPage, - schedule_for_cleanup: Callable[..., None], - ) -> None: - # GIVEN a wiki page with markdown - root_wiki = wiki_page_with_markdown - - # AND a download location - download_dir = tempfile.mkdtemp() - - # WHEN downloading the markdown file - downloaded_path = WikiPage( - owner_id=root_wiki.owner_id, id=root_wiki.id - ).get_markdown_file( - download_file=True, download_location=download_dir, synapse_client=self.syn - ) - # THEN the file should be downloaded and unzipped - assert os.path.exists(downloaded_path) - # Verify content - with open(downloaded_path, "r", encoding="utf-8") as f: - content = f.read() - assert "Sub Wiki Markdown" in content - schedule_for_cleanup(download_dir) - - @pytest.fixture(scope="function") - def wiki_page_with_markdown_gz( - self, - syn: Synapse, - schedule_for_cleanup: Callable[..., None], - wiki_page_fixture: WikiPage, - ) -> WikiPage: - # GIVEN a wiki page with markdown - root_wiki = wiki_page_fixture - filename = utils.make_bogus_uuid_file() - # Rename to add .md.gz extension - md_gz_filename = filename.replace(".txt", ".md.gz") - os.rename(filename, md_gz_filename) - with gzip.open(md_gz_filename, "wt") as f: - f.write("# Test Wiki\n\nThis is test content.") - schedule_for_cleanup(md_gz_filename) - - # Create wiki page with markdown gz - wiki_page = WikiPage( - owner_id=root_wiki.owner_id, - title=f"Test Wiki with GZ Markdown {str(uuid.uuid4())}", - markdown="# Test Wiki with GZ Markdown\n\nThis is test content.", - parent_id=root_wiki.id, - ) - sub_wiki = wiki_page.store(synapse_client=syn) - schedule_for_cleanup(sub_wiki.id) - return sub_wiki - - def test_get_markdown_url_gz_file( - self, - wiki_page_with_markdown_gz: WikiPage, - schedule_for_cleanup: Callable[..., None], - ) -> None: - # GIVEN a wiki page with markdown gz - root_wiki = wiki_page_with_markdown_gz - - # WHEN getting markdown URL - markdown_url = WikiPage( - owner_id=root_wiki.owner_id, id=root_wiki.id - ).get_markdown_file(download_file=False, synapse_client=self.syn) - schedule_for_cleanup(markdown_url) - # THEN a URL should be returned - assert len(markdown_url) > 0 - - def test_download_markdown_file_gz_file( - self, - wiki_page_with_markdown_gz: WikiPage, - schedule_for_cleanup: Callable[..., None], - ) -> None: - # GIVEN a wiki page with markdown gz - root_wiki = wiki_page_with_markdown_gz - - # AND a download location - download_dir = tempfile.mkdtemp() - - # WHEN downloading the markdown file - downloaded_path = WikiPage( - owner_id=root_wiki.owner_id, id=root_wiki.id - ).get_markdown_file( - download_file=True, download_location=download_dir, synapse_client=self.syn - ) - schedule_for_cleanup(downloaded_path) - # THEN the file should be downloaded and unzipped - assert os.path.exists(downloaded_path) - # Verify content - with open(downloaded_path, "r", encoding="utf-8") as f: - content = f.read() - assert "Test Wiki" in content - schedule_for_cleanup(download_dir) - - -class TestWikiPageVersioning: - """Tests for WikiPage version operations.""" - - @pytest.fixture(autouse=True, scope="function") - def init(self, syn: Synapse, schedule_for_cleanup: Callable[..., None]) -> None: - self.syn = syn - self.schedule_for_cleanup = schedule_for_cleanup - - @pytest.fixture(scope="function") - def wiki_page_with_multiple_versions( - self, - syn: Synapse, - schedule_for_cleanup: Callable[..., None], - wiki_page_fixture: WikiPage, - ) -> WikiPage: - # GIVEN a wiki page with multiple versions - root_wiki = wiki_page_fixture - wiki_page = WikiPage( - owner_id=root_wiki.owner_id, - title=f"Sub Wiki Versioning {str(uuid.uuid4())}", - markdown="# Sub Wiki Versioning\n\nThis is a sub wiki versioning page.", - parent_id=root_wiki.id, - ) - updated_wiki = wiki_page.store(synapse_client=syn) - # Update the wiki page - updated_wiki = WikiPage( - owner_id=root_wiki.owner_id, id=updated_wiki.id, title="Version 1" - ).store(synapse_client=syn) - # Update the wiki page - updated_wiki = WikiPage( - owner_id=root_wiki.owner_id, id=updated_wiki.id, title="Version 2" - ).store(synapse_client=syn) - schedule_for_cleanup(updated_wiki.id) - return updated_wiki - - def test_wiki_page_history( - self, - wiki_page_with_multiple_versions, - schedule_for_cleanup: Callable[..., None], - ) -> None: - # GIVEN a wiki page with multiple versions - sub_wiki = wiki_page_with_multiple_versions - # WHEN getting wiki history - history = [] - for item in WikiHistorySnapshot.get( - owner_id=sub_wiki.owner_id, id=sub_wiki.id, synapse_client=self.syn - ): - history.append(item) - # THEN history should be returned - assert len(history) == 3 - schedule_for_cleanup(history) - - def test_restore_wiki_page_version( - self, - wiki_page_with_multiple_versions, - schedule_for_cleanup: Callable[..., None], - ) -> None: - # GIVEN a wiki page with multiple versions - root_wiki = wiki_page_with_multiple_versions - # Get initial version - initial_version = "0" - # WHEN restoring to the initial version - restored_wiki = WikiPage( - owner_id=root_wiki.owner_id, - id=root_wiki.id, - wiki_version=initial_version, - ).restore(synapse_client=self.syn) - - # THEN the wiki should be restored - assert "Sub Wiki Versioning" in restored_wiki.title - schedule_for_cleanup(restored_wiki) - - -class TestWikiHeader: - """Tests for WikiHeader operations.""" - - @pytest.fixture(autouse=True, scope="function") - def init(self, syn: Synapse, schedule_for_cleanup: Callable[..., None]) -> None: - self.syn = syn - self.schedule_for_cleanup = schedule_for_cleanup - - def test_get_wiki_header_tree( - self, - wiki_page_fixture: WikiPage, - schedule_for_cleanup: Callable[..., None], - ) -> None: - time.sleep(15) - # WHEN getting the wiki header tree - headers = [] - for header in WikiHeader.get( - owner_id=wiki_page_fixture.owner_id, synapse_client=self.syn - ): - headers.append(header) - - # THEN headers should be returned - assert len(headers) >= 1 - schedule_for_cleanup(headers) - - -class TestWikiOrderHint: - """Tests for WikiOrderHint operations.""" - - @pytest.fixture(autouse=True, scope="function") - def init(self, syn: Synapse, schedule_for_cleanup: Callable[..., None]) -> None: - self.syn = syn - self.schedule_for_cleanup = schedule_for_cleanup - - def test_get_wiki_order_hint( - self, - wiki_page_fixture: WikiPage, - schedule_for_cleanup: Callable[..., None], - ) -> None: - time.sleep(15) - # WHEN getting the wiki order hint - order_hint = WikiOrderHint(owner_id=wiki_page_fixture.owner_id).get( - synapse_client=self.syn - ) - # THEN order hint should be returned - assert ( - len(order_hint.id_list) == 0 - ) # this is expected because the order hint is not set by default - schedule_for_cleanup(order_hint) - - def test_store_wiki_order_hint( - self, wiki_page_fixture: WikiPage, schedule_for_cleanup: Callable[..., None] - ) -> None: - time.sleep(15) - # Get headers - headers = WikiHeader.get( - owner_id=wiki_page_fixture.owner_id, synapse_client=self.syn - ) - # Get the ids of the headers - header_ids = [header.id for header in headers] - # Get initial order hint - order_hint = WikiOrderHint(owner_id=wiki_page_fixture.owner_id).get( - synapse_client=self.syn - ) - schedule_for_cleanup(order_hint) - # WHEN setting a custom order - order_hint.id_list = header_ids - updated_order_hint = order_hint.store(synapse_client=self.syn) - schedule_for_cleanup(updated_order_hint) - time.sleep(15) - # THEN the order hint should be updated - # Retrieve the updated order hint - retrieved_order_hint = WikiOrderHint(owner_id=wiki_page_fixture.owner_id).get( - synapse_client=self.syn - ) - schedule_for_cleanup(retrieved_order_hint) - assert retrieved_order_hint.id_list == header_ids - assert len(retrieved_order_hint.id_list) >= 1 - - # clean up the wiki pages for other tests in the same session - def test_cleanup_wiki_pages(self, wiki_page_fixture: WikiPage): - root_wiki = wiki_page_fixture - root_wiki.delete(synapse_client=self.syn) - assert True diff --git a/tests/integration/synapseclient/operations/synchronous/__init__.py b/tests/integration/synapseclient/operations/synchronous/__init__.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/tests/integration/synapseclient/operations/synchronous/test_delete_operations.py b/tests/integration/synapseclient/operations/synchronous/test_delete_operations.py deleted file mode 100644 index 85f8c6d73..000000000 --- a/tests/integration/synapseclient/operations/synchronous/test_delete_operations.py +++ /dev/null @@ -1,471 +0,0 @@ -"""Integration tests for delete operations synchronous.""" -import uuid -from typing import Callable - -import pytest - -from synapseclient import Synapse -from synapseclient.core import utils -from synapseclient.core.exceptions import SynapseHTTPError -from synapseclient.models import File, Project, RecordSet -from synapseclient.operations import delete - - -class TestDeleteOperations: - """Tests for the delete factory function (synchronous).""" - - @pytest.fixture(autouse=True, scope="function") - def init( - self, syn_with_logger: Synapse, schedule_for_cleanup: Callable[..., None] - ) -> None: - self.syn = syn_with_logger - self.schedule_for_cleanup = schedule_for_cleanup - - def test_delete_file_by_id_string(self, project_model: Project) -> None: - """Test deleting a file using a string ID.""" - # GIVEN a file stored in synapse - filename = utils.make_bogus_uuid_file() - self.schedule_for_cleanup(filename) - - file = File( - path=filename, - parent_id=project_model.id, - description="Test file for deletion", - ) - file = file.store(synapse_client=self.syn) - self.schedule_for_cleanup(file.id) - assert file.id is not None - - # WHEN I delete the file using string ID - delete(file.id, synapse_client=self.syn) - - # THEN I expect the file to be deleted - with pytest.raises(SynapseHTTPError) as e: - File(id=file.id).get(synapse_client=self.syn) - assert f"404 Client Error: Entity {file.id} is in trash can." in str(e.value) - - def test_delete_file_by_object(self, project_model: Project) -> None: - """Test deleting a file using a File object.""" - # GIVEN a file stored in synapse - filename = utils.make_bogus_uuid_file() - self.schedule_for_cleanup(filename) - - file = File( - path=filename, - parent_id=project_model.id, - description="Test file for deletion", - ) - file = file.store(synapse_client=self.syn) - self.schedule_for_cleanup(file.id) - assert file.id is not None - - # WHEN I delete the file using File object - delete(file, synapse_client=self.syn) - - # THEN I expect the file to be deleted - with pytest.raises(SynapseHTTPError) as e: - File(id=file.id).get(synapse_client=self.syn) - assert f"404 Client Error: Entity {file.id} is in trash can." in str(e.value) - - def test_delete_file_specific_version_with_version_param( - self, project_model: Project, caplog: pytest.LogCaptureFixture - ) -> None: - """Test deleting a specific version using version parameter (highest priority).""" - # GIVEN a file with multiple versions - filename = utils.make_bogus_uuid_file() - self.schedule_for_cleanup(filename) - - file = File( - path=filename, - parent_id=project_model.id, - description="Test file version 1", - ) - file = file.store(synapse_client=self.syn) - self.schedule_for_cleanup(file.id) - assert file.version_number == 1 - - # Create version 2 - file.description = "Test file version 2" - file = file.store(synapse_client=self.syn) - assert file.version_number == 2 - - # Create version 3 - file.description = "Test file version 3" - file = file.store(synapse_client=self.syn) - assert file.version_number == 3 - - # WHEN I delete version 2 using version parameter with version_only=True - file_v2 = File(id=file.id, version_number=999) # Set wrong version on entity - - # Capture logs - caplog.clear() - delete( - file_v2, - version=2, # This should take precedence over entity's version_number - version_only=True, - synapse_client=self.syn, - ) - - # Check that warning was logged - assert any("Version conflict" in record.message for record in caplog.records) - assert any( - "version' parameter (2)" in record.message for record in caplog.records - ) - - # THEN version 2 should be deleted - with pytest.raises(SynapseHTTPError) as e: - File(id=file.id, version_number=2).get(synapse_client=self.syn) - assert f"Cannot find a node with id {file.id} and version 2" in str(e.value) - - # AND version 1 and 3 should still exist - file_v1 = File(id=file.id, version_number=1).get(synapse_client=self.syn) - assert file_v1.version_number == 1 - - file_v3 = File(id=file.id, version_number=3).get(synapse_client=self.syn) - assert file_v3.version_number == 3 - - def test_delete_file_specific_version_with_entity_version_number( - self, project_model: Project - ) -> None: - """Test deleting a specific version using entity's version_number attribute.""" - # GIVEN a file with multiple versions - filename = utils.make_bogus_uuid_file() - self.schedule_for_cleanup(filename) - - file = File( - path=filename, - parent_id=project_model.id, - description="Test file version 1", - ) - file = file.store(synapse_client=self.syn) - self.schedule_for_cleanup(file.id) - assert file.version_number == 1 - - # Create version 2 - file.description = "Test file version 2" - file = file.store(synapse_client=self.syn) - assert file.version_number == 2 - - # WHEN I delete version 1 using entity's version_number - file_v1 = File(id=file.id, version_number=1) - - delete(file_v1, version_only=True, synapse_client=self.syn) - - # THEN version 1 should be deleted - with pytest.raises(SynapseHTTPError) as e: - File(id=file.id, version_number=1).get(synapse_client=self.syn) - assert f"Cannot find a node with id {file.id} and version 1" in str(e.value) - - # AND version 2 should still exist - file_v2 = File(id=file.id, version_number=2).get(synapse_client=self.syn) - assert file_v2.version_number == 2 - - def test_delete_file_specific_version_with_id_string( - self, project_model: Project - ) -> None: - """Test deleting a specific version using ID string with version.""" - # GIVEN a file with multiple versions - filename = utils.make_bogus_uuid_file() - self.schedule_for_cleanup(filename) - - file = File( - path=filename, - parent_id=project_model.id, - description="Test file version 1", - ) - file = file.store(synapse_client=self.syn) - self.schedule_for_cleanup(file.id) - assert file.version_number == 1 - - # Create version 2 - file.description = "Test file version 2" - file = file.store(synapse_client=self.syn) - assert file.version_number == 2 - - # WHEN I delete version 1 using string ID with version - delete(f"{file.id}.1", version_only=True, synapse_client=self.syn) - - # THEN version 1 should be deleted - with pytest.raises(SynapseHTTPError) as e: - File(id=file.id, version_number=1).get(synapse_client=self.syn) - assert f"Cannot find a node with id {file.id} and version 1" in str(e.value) - - # AND version 2 should still exist - file_v2 = File(id=file.id, version_number=2).get(synapse_client=self.syn) - assert file_v2.version_number == 2 - - def test_delete_recordset_specific_version(self, project_model: Project) -> None: - """Test deleting a specific version of a RecordSet.""" - # GIVEN a RecordSet with multiple versions - filename1 = utils.make_bogus_uuid_file() - filename2 = utils.make_bogus_uuid_file() - self.schedule_for_cleanup(filename1) - self.schedule_for_cleanup(filename2) - - record_set = RecordSet( - name=str(uuid.uuid4()), - path=filename1, - description="RecordSet version 1", - parent_id=project_model.id, - upsert_keys=["id"], - ) - record_set = record_set.store(synapse_client=self.syn) - self.schedule_for_cleanup(record_set.id) - assert record_set.version_number == 1 - - # Create version 2 - record_set.path = filename2 - record_set.description = "RecordSet version 2" - record_set = record_set.store(synapse_client=self.syn) - assert record_set.version_number == 2 - - # WHEN I delete version 2 using version parameter - delete( - RecordSet(id=record_set.id, version_number=2), - version_only=True, - synapse_client=self.syn, - ) - - # THEN version 2 should be gone and version 1 should be current - current_record_set = RecordSet(id=record_set.id).get(synapse_client=self.syn) - assert current_record_set.version_number == 1 - assert current_record_set.description == "RecordSet version 1" - - def test_delete_version_only_without_version_raises_error( - self, project_model: Project - ) -> None: - """Test that version_only=True without a version number raises an error.""" - # GIVEN a file without version_number set - filename = utils.make_bogus_uuid_file() - self.schedule_for_cleanup(filename) - - file = File( - path=filename, - parent_id=project_model.id, - description="Test file", - ) - file = file.store(synapse_client=self.syn) - self.schedule_for_cleanup(file.id) - - # WHEN I try to delete with version_only=True but no version - file_no_version = File(id=file.id) - - # THEN it should raise ValueError - with pytest.raises(ValueError) as e: - delete(file_no_version, version_only=True, synapse_client=self.syn) - assert "version_only=True requires a version number" in str(e.value) - - def test_delete_project_ignores_version_parameters( - self, caplog: pytest.LogCaptureFixture - ) -> None: - """Test that deleting a Project ignores version parameters with warning.""" - # GIVEN a project - project = Project( - name=str(uuid.uuid4()), - description="Test project for version parameter", - ) - project = project.store(synapse_client=self.syn) - self.schedule_for_cleanup(project.id) - - # WHEN I try to delete with version_only=True - caplog.clear() - delete(project, version_only=True, synapse_client=self.syn) - - # THEN warnings should be logged - assert any( - "does not support version-specific deletion" in record.message - for record in caplog.records - ) - - # AND the entire project should be deleted - with pytest.raises(SynapseHTTPError) as e: - Project(id=project.id).get(synapse_client=self.syn) - assert f"404 Client Error: Entity {project.id} is in trash can." in str(e.value) - - def test_delete_invalid_synapse_id_raises_error(self) -> None: - """Test that an invalid Synapse ID raises an error.""" - # WHEN I try to delete with an invalid ID - # THEN it should raise ValueError - with pytest.raises(ValueError) as e: - delete("invalid_id", synapse_client=self.syn) - assert "Invalid Synapse ID: invalid_id" in str(e.value) - - def test_delete_with_dot_notation_without_version_only_raises_error( - self, project_model: Project - ) -> None: - """Test that using dot notation without version_only=True raises an error.""" - # GIVEN a file with multiple versions - filename = utils.make_bogus_uuid_file() - self.schedule_for_cleanup(filename) - - file = File( - path=filename, - parent_id=project_model.id, - description="Test file version 1", - ) - file = file.store(synapse_client=self.syn) - self.schedule_for_cleanup(file.id) - - # WHEN I try to delete with dot notation but without version_only=True - # THEN it should raise ValueError - with pytest.raises(ValueError) as e: - delete(f"{file.id}.1", synapse_client=self.syn) - assert "Deleting a specific version requires version_only=True" in str(e.value) - assert f"delete('{file.id}.1', version_only=True)" in str(e.value) - - def test_version_precedence_version_param_over_entity_attribute( - self, project_model: Project - ) -> None: - """Test that version parameter takes precedence over entity's version_number.""" - # GIVEN a file with multiple versions - filename = utils.make_bogus_uuid_file() - self.schedule_for_cleanup(filename) - - file = File( - path=filename, - parent_id=project_model.id, - description="Test file version 1", - ) - file = file.store(synapse_client=self.syn) - self.schedule_for_cleanup(file.id) - - file.description = "Test file version 2" - file = file.store(synapse_client=self.syn) - - file.description = "Test file version 3" - file = file.store(synapse_client=self.syn) - - # WHEN I have entity with version_number=1 but pass version=2 - file_entity = File(id=file.id, version_number=1) - - # version=2 should take precedence - delete(file_entity, version=2, version_only=True, synapse_client=self.syn) - - # THEN version 2 should be deleted (not version 1) - with pytest.raises(SynapseHTTPError): - File(id=file.id, version_number=2).get(synapse_client=self.syn) - - # AND version 1 should still exist - file_v1 = File(id=file.id, version_number=1).get(synapse_client=self.syn) - assert file_v1.version_number == 1 - - def test_delete_version_param_without_conflict_no_warning( - self, project_model: Project, caplog: pytest.LogCaptureFixture - ) -> None: - """Test that no warning is logged when version parameter is used without conflict.""" - # GIVEN a file with multiple versions - filename = utils.make_bogus_uuid_file() - self.schedule_for_cleanup(filename) - - file = File( - path=filename, - parent_id=project_model.id, - description="Test file version 1", - ) - file = file.store(synapse_client=self.syn) - self.schedule_for_cleanup(file.id) - - file.description = "Test file version 2" - file = file.store(synapse_client=self.syn) - - # WHEN I delete with version parameter but entity has no version_number set - file_no_version = File(id=file.id) # No version_number attribute set - - caplog.clear() - delete(file_no_version, version=1, version_only=True, synapse_client=self.syn) - - # THEN no version conflict warning should be logged - assert not any( - "Version conflict" in record.message for record in caplog.records - ) - - # AND version 1 should be deleted - with pytest.raises(SynapseHTTPError): - File(id=file.id, version_number=1).get(synapse_client=self.syn) - - def test_delete_folder_with_version_only_logs_warning( - self, project_model: Project, caplog: pytest.LogCaptureFixture - ) -> None: - """Test that deleting a Folder with version_only=True logs a warning.""" - from synapseclient.models import Folder - - # GIVEN a folder - folder = Folder( - name=str(uuid.uuid4()), - parent_id=project_model.id, - ) - folder = folder.store(synapse_client=self.syn) - self.schedule_for_cleanup(folder.id) - - # WHEN I try to delete with version_only=True - caplog.clear() - delete(folder, version_only=True, synapse_client=self.syn) - - # THEN warning should be logged - assert any( - "does not support version-specific deletion" in record.message - for record in caplog.records - ) - assert any("Folder" in record.message for record in caplog.records) - - def test_no_warning_when_version_only_false_despite_conflict( - self, project_model: Project, caplog: pytest.LogCaptureFixture - ) -> None: - """Test that no warning is logged when version_only=False even with version conflict.""" - # GIVEN a file - filename = utils.make_bogus_uuid_file() - self.schedule_for_cleanup(filename) - - file = File( - path=filename, - parent_id=project_model.id, - description="Test file", - ) - file = file.store(synapse_client=self.syn) - self.schedule_for_cleanup(file.id) - - # WHEN I delete with version parameter but version_only=False - file_entity = File(id=file.id, version_number=999) - - caplog.clear() - delete( - file_entity, - version=1, - version_only=False, # Not deleting specific version - synapse_client=self.syn, - ) - - # THEN no version conflict warning should be logged (since version_only=False) - assert not any( - "Version conflict" in record.message for record in caplog.records - ) - - def test_delete_file_with_version_number_none_no_warning( - self, project_model: Project, caplog: pytest.LogCaptureFixture - ) -> None: - """Test that no warning when entity.version_number is explicitly None.""" - # GIVEN a file with multiple versions - filename = utils.make_bogus_uuid_file() - self.schedule_for_cleanup(filename) - - file = File( - path=filename, - parent_id=project_model.id, - description="Test file version 1", - ) - file = file.store(synapse_client=self.syn) - self.schedule_for_cleanup(file.id) - - file.description = "Test file version 2" - file = file.store(synapse_client=self.syn) - - # WHEN I have entity with version_number=None but pass version parameter - file_entity = File(id=file.id, version_number=None) - - caplog.clear() - delete(file_entity, version=1, version_only=True, synapse_client=self.syn) - - # THEN no conflict warning should be logged (None is not a conflict) - assert not any( - "Version conflict" in record.message for record in caplog.records - ) diff --git a/tests/integration/synapseclient/operations/synchronous/test_factory_operations.py b/tests/integration/synapseclient/operations/synchronous/test_factory_operations.py deleted file mode 100644 index a6ead3f0e..000000000 --- a/tests/integration/synapseclient/operations/synchronous/test_factory_operations.py +++ /dev/null @@ -1,795 +0,0 @@ -"""Integration tests for the synapseclient.models.factory_operations get function.""" - -import tempfile -import uuid -from typing import Callable - -import pytest - -from synapseclient import Synapse -from synapseclient.api.table_services import ViewTypeMask -from synapseclient.core import utils -from synapseclient.models import ( - Activity, - Column, - ColumnType, - Dataset, - DatasetCollection, - EntityView, - File, - Folder, - Link, - MaterializedView, - Project, - SubmissionView, - Table, - UsedEntity, - UsedURL, - VirtualTable, -) -from synapseclient.operations import ( - ActivityOptions, - FileOptions, - LinkOptions, - TableOptions, - get, -) - - -class TestFactoryOperationsGetAsync: - """Tests for the synapseclient.models.factory_operations.get method.""" - - @pytest.fixture(autouse=True, scope="function") - def init(self, syn: Synapse, schedule_for_cleanup: Callable[..., None]) -> None: - self.syn = syn - self.schedule_for_cleanup = schedule_for_cleanup - - def create_file_instance(self) -> File: - """Helper method to create a test file.""" - filename = utils.make_bogus_uuid_file() - self.schedule_for_cleanup(filename) - return File( - path=filename, - description="Test file for factory operations", - content_type="text/plain", - name=f"test_file_{str(uuid.uuid4())[:8]}.txt", - ) - - def create_activity(self) -> Activity: - """Helper method to create a test activity.""" - return Activity( - name="Test Activity", - description="Activity for testing factory operations", - used=[ - UsedURL(name="example", url="https://www.synapse.org/"), - UsedEntity(target_id="syn123456", target_version_number=1), - ], - ) - - def test_get_project_by_id(self, project_model: Project) -> None: - """Test retrieving a Project entity by Synapse ID.""" - # GIVEN a project exists - project_id = project_model.id - - # WHEN I retrieve the project using get - retrieved_project = get(synapse_id=project_id, synapse_client=self.syn) - - # THEN the correct Project entity is returned - assert isinstance(retrieved_project, Project) - assert retrieved_project.id == project_id - assert retrieved_project.name == project_model.name - assert retrieved_project.description == project_model.description - assert retrieved_project.parent_id is not None - assert retrieved_project.etag is not None - assert retrieved_project.created_on is not None - assert retrieved_project.modified_on is not None - assert retrieved_project.created_by is not None - assert retrieved_project.modified_by is not None - - def test_get_project_by_name(self, project_model: Project) -> None: - """Test retrieving a Project entity by name.""" - # GIVEN a project exists - project_name = project_model.name - - # WHEN I retrieve the project using get with entity_name - retrieved_project = get( - entity_name=project_name, parent_id=None, synapse_client=self.syn - ) - - # THEN the correct Project entity is returned - assert isinstance(retrieved_project, Project) - assert retrieved_project.id == project_model.id - assert retrieved_project.name == project_name - - def test_get_folder_by_id(self, project_model: Project) -> None: - """Test retrieving a Folder entity by Synapse ID.""" - # GIVEN a folder in a project - folder = Folder( - name=f"test_folder_{str(uuid.uuid4())[:8]}", - description="Test folder for factory operations", - parent_id=project_model.id, - ) - stored_folder = folder.store(synapse_client=self.syn) - self.schedule_for_cleanup(stored_folder.id) - - # WHEN I retrieve the folder using get - retrieved_folder = get(synapse_id=stored_folder.id, synapse_client=self.syn) - - # THEN the correct Folder entity is returned - assert isinstance(retrieved_folder, Folder) - assert retrieved_folder.id == stored_folder.id - assert retrieved_folder.name == stored_folder.name - assert retrieved_folder.description == stored_folder.description - assert retrieved_folder.parent_id == project_model.id - assert retrieved_folder.etag is not None - assert retrieved_folder.created_on is not None - - def test_get_folder_by_name(self, project_model: Project) -> None: - """Test retrieving a Folder entity by name and parent ID.""" - # GIVEN a folder in a project - folder_name = f"test_folder_{str(uuid.uuid4())[:8]}" - folder = Folder( - name=folder_name, - description="Test folder for factory operations", - parent_id=project_model.id, - ) - stored_folder = folder.store(synapse_client=self.syn) - self.schedule_for_cleanup(stored_folder.id) - - # WHEN I retrieve the folder using get with entity_name - retrieved_folder = get( - entity_name=folder_name, parent_id=project_model.id, synapse_client=self.syn - ) - - # THEN the correct Folder entity is returned - assert isinstance(retrieved_folder, Folder) - assert retrieved_folder.id == stored_folder.id - assert retrieved_folder.name == folder_name - - def test_get_file_by_id_default_options(self, project_model: Project) -> None: - """Test retrieving a File entity by Synapse ID with default options.""" - # GIVEN a file in a project - file = self.create_file_instance() - file.parent_id = project_model.id - stored_file = file.store(synapse_client=self.syn) - self.schedule_for_cleanup(stored_file.id) - - # WHEN I retrieve the file using get with default options - retrieved_file = get(synapse_id=stored_file.id, synapse_client=self.syn) - - # THEN the correct File entity is returned with default behavior - assert isinstance(retrieved_file, File) - assert retrieved_file.id == stored_file.id - assert retrieved_file.name == stored_file.name - assert retrieved_file.path is not None # File should be downloaded by default - assert retrieved_file.download_file is True - assert retrieved_file.data_file_handle_id is not None - assert retrieved_file.file_handle is not None - - def test_get_file_by_id_with_file_options(self, project_model: Project) -> None: - """Test retrieving a File entity by Synapse ID with custom FileOptions.""" - # GIVEN a file in a project - file = self.create_file_instance() - file.parent_id = project_model.id - stored_file = file.store(synapse_client=self.syn) - self.schedule_for_cleanup(stored_file.id) - - # AND custom file download options - with tempfile.TemporaryDirectory() as temp_dir: - file_options = FileOptions( - download_file=True, - download_location=temp_dir, - if_collision="overwrite.local", - ) - - # WHEN I retrieve the file using get with custom options - retrieved_file = get( - synapse_id=stored_file.id, - file_options=file_options, - synapse_client=self.syn, - ) - - # THEN the file is retrieved with the specified options - assert isinstance(retrieved_file, File) - assert retrieved_file.id == stored_file.id - assert retrieved_file.download_file is True - assert retrieved_file.if_collision == "overwrite.local" - assert utils.normalize_path(temp_dir) in utils.normalize_path( - retrieved_file.path - ) - - def test_get_file_by_id_metadata_only(self, project_model: Project) -> None: - """Test retrieving a File entity metadata without downloading.""" - # GIVEN a file in a project - file = self.create_file_instance() - file.parent_id = project_model.id - stored_file = file.store(synapse_client=self.syn) - self.schedule_for_cleanup(stored_file.id) - - # AND file options to skip download - file_options = FileOptions(download_file=False) - - # WHEN I retrieve the file using get without downloading - retrieved_file = get( - synapse_id=stored_file.id, - file_options=file_options, - synapse_client=self.syn, - ) - - # THEN the file metadata is retrieved without download - assert isinstance(retrieved_file, File) - assert retrieved_file.id == stored_file.id - assert retrieved_file.download_file is False - assert retrieved_file.data_file_handle_id is not None - - def test_get_file_by_id_with_activity(self, project_model: Project) -> None: - """Test retrieving a File entity with activity information.""" - # GIVEN a file with activity in a project - file = self.create_file_instance() - file.parent_id = project_model.id - file.activity = self.create_activity() - stored_file = file.store(synapse_client=self.syn) - self.schedule_for_cleanup(stored_file.id) - - # AND activity options to include activity - activity_options = ActivityOptions(include_activity=True) - - # WHEN I retrieve the file using get with activity options - retrieved_file = get( - synapse_id=stored_file.id, - activity_options=activity_options, - synapse_client=self.syn, - ) - - # THEN the file is retrieved with activity information - assert isinstance(retrieved_file, File) - assert retrieved_file.id == stored_file.id - assert retrieved_file.activity is not None - assert retrieved_file.activity.name == "Test Activity" - assert ( - retrieved_file.activity.description - == "Activity for testing factory operations" - ) - - def test_get_file_by_id_specific_version(self, project_model: Project) -> None: - """Test retrieving a specific version of a File entity.""" - # GIVEN a file in a project - file = self.create_file_instance() - file.parent_id = project_model.id - file.version_comment = "Version 1" - stored_file = file.store(synapse_client=self.syn) - self.schedule_for_cleanup(stored_file.id) - - # AND I update the file to create version 2 - file.version_comment = "Version 2" - file.store(synapse_client=self.syn) - - # WHEN I retrieve version 1 specifically - retrieved_file = get( - synapse_id=stored_file.id, version_number=1, synapse_client=self.syn - ) - - # THEN version 1 is returned - assert isinstance(retrieved_file, File) - assert retrieved_file.id == stored_file.id - assert retrieved_file.version_number == 1 - assert retrieved_file.version_comment == "Version 1" - - def test_get_table_by_id_default_options(self, project_model: Project) -> None: - """Test retrieving a Table entity by Synapse ID with default options.""" - # GIVEN a table in a project - columns = [ - Column(name="col1", column_type=ColumnType.STRING, maximum_size=50), - Column(name="col2", column_type=ColumnType.INTEGER), - ] - table = Table( - name=f"test_table_{str(uuid.uuid4())[:8]}", - description="Test table for factory operations", - parent_id=project_model.id, - columns=columns, - ) - stored_table = table.store(synapse_client=self.syn) - self.schedule_for_cleanup(stored_table.id) - - # WHEN I retrieve the table using get - retrieved_table = get(synapse_id=stored_table.id, synapse_client=self.syn) - - # THEN the correct Table entity is returned with columns - assert isinstance(retrieved_table, Table) - assert retrieved_table.id == stored_table.id - assert retrieved_table.name == stored_table.name - assert len(retrieved_table.columns) == 2 - assert any(col.name == "col1" for col in retrieved_table.columns.values()) - assert any(col.name == "col2" for col in retrieved_table.columns.values()) - - def test_get_table_by_id_with_table_options(self, project_model: Project) -> None: - """Test retrieving a Table entity with custom TableOptions.""" - # GIVEN a table in a project - columns = [ - Column(name="col1", column_type=ColumnType.STRING, maximum_size=50), - Column(name="col2", column_type=ColumnType.INTEGER), - ] - table = Table( - name=f"test_table_{str(uuid.uuid4())[:8]}", - description="Test table for factory operations", - parent_id=project_model.id, - columns=columns, - ) - stored_table = table.store(synapse_client=self.syn) - self.schedule_for_cleanup(stored_table.id) - - # AND table options to exclude columns - table_options = TableOptions(include_columns=False) - - # WHEN I retrieve the table using get without columns - retrieved_table = get( - synapse_id=stored_table.id, - table_options=table_options, - synapse_client=self.syn, - ) - - # THEN the table is retrieved without column information - assert isinstance(retrieved_table, Table) - assert retrieved_table.id == stored_table.id - assert len(retrieved_table.columns) == 0 - - def test_get_table_by_id_with_activity(self, project_model: Project) -> None: - """Test retrieving a Table entity with activity information.""" - # GIVEN a table with activity in a project - columns = [ - Column(name="col1", column_type=ColumnType.STRING, maximum_size=50), - ] - table = Table( - name=f"test_table_{str(uuid.uuid4())[:8]}", - description="Test table for factory operations", - parent_id=project_model.id, - columns=columns, - activity=self.create_activity(), - ) - stored_table = table.store(synapse_client=self.syn) - self.schedule_for_cleanup(stored_table.id) - - # AND activity options to include activity - activity_options = ActivityOptions(include_activity=True) - - # WHEN I retrieve the table using get with activity options - retrieved_table = get( - synapse_id=stored_table.id, - activity_options=activity_options, - synapse_client=self.syn, - ) - - # THEN the table is retrieved with activity information - assert isinstance(retrieved_table, Table) - assert retrieved_table.id == stored_table.id - assert retrieved_table.activity is not None - assert retrieved_table.activity.name == "Test Activity" - - def test_get_dataset_by_id(self, project_model: Project) -> None: - """Test retrieving a Dataset entity by Synapse ID.""" - # GIVEN a dataset in a project - columns = [ - Column(name="itemId", column_type=ColumnType.ENTITYID), - Column(name="name", column_type=ColumnType.STRING, maximum_size=256), - ] - dataset = Dataset( - name=f"test_dataset_{str(uuid.uuid4())[:8]}", - description="Test dataset for factory operations", - parent_id=project_model.id, - columns=columns, - include_default_columns=False, - ) - stored_dataset = dataset.store(synapse_client=self.syn) - self.schedule_for_cleanup(stored_dataset.id) - - # WHEN I retrieve the dataset using get - retrieved_dataset = get(synapse_id=stored_dataset.id, synapse_client=self.syn) - - # THEN the correct Dataset entity is returned - assert isinstance(retrieved_dataset, Dataset) - assert retrieved_dataset.id == stored_dataset.id - assert retrieved_dataset.name == stored_dataset.name - assert len(retrieved_dataset.columns) == 2 - - def test_get_dataset_collection_by_id(self, project_model: Project) -> None: - """Test retrieving a DatasetCollection entity by Synapse ID.""" - # GIVEN a dataset collection in a project - dataset_collection = DatasetCollection( - name=f"test_dataset_collection_{str(uuid.uuid4())[:8]}", - description="Test dataset collection for factory operations", - parent_id=project_model.id, - include_default_columns=False, - ) - stored_collection = dataset_collection.store(synapse_client=self.syn) - self.schedule_for_cleanup(stored_collection.id) - - # WHEN I retrieve the dataset collection using get - retrieved_collection = get( - synapse_id=stored_collection.id, synapse_client=self.syn - ) - - # THEN the correct DatasetCollection entity is returned - assert isinstance(retrieved_collection, DatasetCollection) - assert retrieved_collection.id == stored_collection.id - assert retrieved_collection.name == stored_collection.name - - def test_get_entity_view_by_id(self, project_model: Project) -> None: - """Test retrieving an EntityView entity by Synapse ID.""" - # GIVEN an entity view in a project - columns = [ - Column(name="id", column_type=ColumnType.ENTITYID), - Column(name="name", column_type=ColumnType.STRING, maximum_size=256), - ] - entity_view = EntityView( - name=f"test_entity_view_{str(uuid.uuid4())[:8]}", - description="Test entity view for factory operations", - parent_id=project_model.id, - columns=columns, - scope_ids=[project_model.id], - view_type_mask=ViewTypeMask.FILE, - include_default_columns=False, - ) - stored_view = entity_view.store(synapse_client=self.syn) - self.schedule_for_cleanup(stored_view.id) - - # WHEN I retrieve the entity view using get - retrieved_view = get(synapse_id=stored_view.id, synapse_client=self.syn) - - # THEN the correct EntityView entity is returned - assert isinstance(retrieved_view, EntityView) - assert retrieved_view.id == stored_view.id - assert retrieved_view.name == stored_view.name - assert len(retrieved_view.columns) >= 2 # May include default columns - - def test_get_submission_view_by_id(self, project_model: Project) -> None: - """Test retrieving a SubmissionView entity by Synapse ID.""" - # GIVEN a submission view in a project - columns = [ - Column(name="id", column_type=ColumnType.SUBMISSIONID), - Column(name="name", column_type=ColumnType.STRING, maximum_size=256), - ] - submission_view = SubmissionView( - name=f"test_submission_view_{str(uuid.uuid4())[:8]}", - description="Test submission view for factory operations", - parent_id=project_model.id, - columns=columns, - scope_ids=[project_model.id], - include_default_columns=False, - ) - stored_view = submission_view.store(synapse_client=self.syn) - self.schedule_for_cleanup(stored_view.id) - - # WHEN I retrieve the submission view using get - retrieved_view = get(synapse_id=stored_view.id, synapse_client=self.syn) - - # THEN the correct SubmissionView entity is returned - assert isinstance(retrieved_view, SubmissionView) - assert retrieved_view.id == stored_view.id - assert retrieved_view.name == stored_view.name - - def test_get_materialized_view_by_id(self, project_model: Project) -> None: - """Test retrieving a MaterializedView entity by Synapse ID.""" - # GIVEN a simple table to create materialized view from - columns = [ - Column(name="col1", column_type=ColumnType.STRING, maximum_size=50), - Column(name="col2", column_type=ColumnType.INTEGER), - ] - source_table = Table( - name=f"source_table_{str(uuid.uuid4())[:8]}", - parent_id=project_model.id, - columns=columns, - ) - stored_source = source_table.store(synapse_client=self.syn) - self.schedule_for_cleanup(stored_source.id) - - # AND a materialized view - materialized_view = MaterializedView( - name=f"test_materialized_view_{str(uuid.uuid4())[:8]}", - description="Test materialized view for factory operations", - parent_id=project_model.id, - defining_sql=f"SELECT * FROM {stored_source.id}", - ) - stored_view = materialized_view.store(synapse_client=self.syn) - self.schedule_for_cleanup(stored_view.id) - - # WHEN I retrieve the materialized view using get - retrieved_view = get(synapse_id=stored_view.id, synapse_client=self.syn) - - # THEN the correct MaterializedView entity is returned - assert isinstance(retrieved_view, MaterializedView) - assert retrieved_view.id == stored_view.id - assert retrieved_view.name == stored_view.name - assert retrieved_view.defining_sql is not None - - def test_get_virtual_table_by_id(self, project_model: Project) -> None: - """Test retrieving a VirtualTable entity by Synapse ID.""" - # GIVEN a simple table to create virtual table from - columns = [ - Column(name="col1", column_type=ColumnType.STRING, maximum_size=50), - Column(name="col2", column_type=ColumnType.INTEGER), - ] - source_table = Table( - name=f"source_table_{str(uuid.uuid4())[:8]}", - parent_id=project_model.id, - columns=columns, - ) - stored_source = source_table.store(synapse_client=self.syn) - self.schedule_for_cleanup(stored_source.id) - - # AND a virtual table - virtual_table = VirtualTable( - name=f"test_virtual_table_{str(uuid.uuid4())[:8]}", - description="Test virtual table for factory operations", - parent_id=project_model.id, - defining_sql=f"SELECT * FROM {stored_source.id}", - ) - stored_virtual = virtual_table.store(synapse_client=self.syn) - self.schedule_for_cleanup(stored_virtual.id) - - # WHEN I retrieve the virtual table using get - retrieved_virtual = get(synapse_id=stored_virtual.id, synapse_client=self.syn) - - # THEN the correct VirtualTable entity is returned - assert isinstance(retrieved_virtual, VirtualTable) - assert retrieved_virtual.id == stored_virtual.id - assert retrieved_virtual.name == stored_virtual.name - assert retrieved_virtual.defining_sql is not None - - def test_get_link_by_id_without_following(self, project_model: Project) -> None: - """Test retrieving a Link entity by Synapse ID without following the link.""" - # GIVEN a file and a link to that file - file = self.create_file_instance() - file.parent_id = project_model.id - stored_file = file.store(synapse_client=self.syn) - self.schedule_for_cleanup(stored_file.id) - - link = Link( - name=f"test_link_{str(uuid.uuid4())[:8]}", - description="Test link for factory operations", - parent_id=project_model.id, - target_id=stored_file.id, - ) - stored_link = link.store(synapse_client=self.syn) - self.schedule_for_cleanup(stored_link.id) - - # AND link options to not follow the link - link_options = LinkOptions(follow_link=False) - - # WHEN I retrieve the link using get without following - retrieved_link = get( - synapse_id=stored_link.id, - link_options=link_options, - synapse_client=self.syn, - ) - - # THEN the Link entity itself is returned - assert isinstance(retrieved_link, Link) - assert retrieved_link.id == stored_link.id - assert retrieved_link.name == stored_link.name - assert retrieved_link.target_id == stored_file.id - - def test_get_link_by_id_default_follows_link(self, project_model: Project) -> None: - """Test that getting a Link by ID follows the link by default (no LinkOptions provided).""" - # GIVEN a file and a link to that file - file = self.create_file_instance() - file.parent_id = project_model.id - stored_file = file.store(synapse_client=self.syn) - self.schedule_for_cleanup(stored_file.id) - - link = Link( - name=f"test_link_{str(uuid.uuid4())[:8]}", - description="Test link for factory operations", - parent_id=project_model.id, - target_id=stored_file.id, - ) - stored_link = link.store(synapse_client=self.syn) - self.schedule_for_cleanup(stored_link.id) - - # WHEN I retrieve the link without any options (should use defaults) - retrieved_entity = get( - synapse_id=stored_link.id, - synapse_client=self.syn, - ) - - # THEN the target File entity is returned (default follow_link=True behavior) - assert isinstance(retrieved_entity, File) - assert retrieved_entity.id == stored_file.id - assert retrieved_entity.name == stored_file.name - - def test_get_link_by_id_with_following(self, project_model: Project) -> None: - """Test retrieving a Link entity by Synapse ID and following to the target (default behavior).""" - # GIVEN a file and a link to that file - file = self.create_file_instance() - file.parent_id = project_model.id - stored_file = file.store(synapse_client=self.syn) - self.schedule_for_cleanup(stored_file.id) - - link = Link( - name=f"test_link_{str(uuid.uuid4())[:8]}", - description="Test link for factory operations", - parent_id=project_model.id, - target_id=stored_file.id, - ) - stored_link = link.store(synapse_client=self.syn) - self.schedule_for_cleanup(stored_link.id) - - # WHEN I retrieve the link using get with default behavior (follow_link=True) - retrieved_entity = get( - synapse_id=stored_link.id, - synapse_client=self.syn, - ) - - # THEN the target File entity is returned instead of the Link (default behavior is now follow_link=True) - assert isinstance(retrieved_entity, File) - assert retrieved_entity.id == stored_file.id - assert retrieved_entity.name == stored_file.name - - def test_get_link_by_id_with_following_explicit( - self, project_model: Project - ) -> None: - """Test retrieving a Link entity by Synapse ID with explicit follow_link=True.""" - # GIVEN a file and a link to that file - file = self.create_file_instance() - file.parent_id = project_model.id - stored_file = file.store(synapse_client=self.syn) - self.schedule_for_cleanup(stored_file.id) - - link = Link( - name=f"test_link_{str(uuid.uuid4())[:8]}", - description="Test link for factory operations", - parent_id=project_model.id, - target_id=stored_file.id, - ) - stored_link = link.store(synapse_client=self.syn) - self.schedule_for_cleanup(stored_link.id) - - # AND link options to follow the link - link_options = LinkOptions(follow_link=True) - - # WHEN I retrieve the link using get with following - retrieved_entity = get( - synapse_id=stored_link.id, - link_options=link_options, - synapse_client=self.syn, - ) - - # THEN the target File entity is returned instead of the Link - assert isinstance(retrieved_entity, File) - assert retrieved_entity.id == stored_file.id - assert retrieved_entity.name == stored_file.name - - def test_get_link_by_id_with_file_options(self, project_model: Project) -> None: - """Test retrieving a Link entity that points to a File with custom file options.""" - # GIVEN a file and a link to that file - file = self.create_file_instance() - file.parent_id = project_model.id - stored_file = file.store(synapse_client=self.syn) - self.schedule_for_cleanup(stored_file.id) - - link = Link( - name=f"test_link_{str(uuid.uuid4())[:8]}", - description="Test link for factory operations", - parent_id=project_model.id, - target_id=stored_file.id, - ) - stored_link = link.store(synapse_client=self.syn) - self.schedule_for_cleanup(stored_link.id) - - # AND custom file download options and link options - with tempfile.TemporaryDirectory() as temp_dir: - file_options = FileOptions( - download_file=True, - download_location=temp_dir, - if_collision="overwrite.local", - ) - link_options = LinkOptions(follow_link=True) - - # WHEN I retrieve the link using get with both link and file options - retrieved_entity = get( - synapse_id=stored_link.id, - link_options=link_options, - file_options=file_options, - synapse_client=self.syn, - ) - - # THEN the target File entity is returned with the custom options applied - assert isinstance(retrieved_entity, File) - assert retrieved_entity.id == stored_file.id - assert retrieved_entity.name == stored_file.name - assert utils.normalize_path(temp_dir) in utils.normalize_path( - retrieved_entity.path - ) - assert retrieved_entity.download_file is True - - def test_get_with_entity_instance(self, project_model: Project) -> None: - """Test get when passing an entity instance directly.""" - # GIVEN an existing File entity instance - file = self.create_file_instance() - file.parent_id = project_model.id - stored_file = file.store(synapse_client=self.syn) - self.schedule_for_cleanup(stored_file.id) - - # AND file options to change behavior - file_options = FileOptions(download_file=False) - - # WHEN I pass the entity instance to get with new options - refreshed_file = get( - stored_file, file_options=file_options, synapse_client=self.syn - ) - - # THEN the entity is refreshed with the new options applied - assert isinstance(refreshed_file, File) - assert refreshed_file.id == stored_file.id - assert refreshed_file.download_file is False - - def test_get_combined_options(self, project_model: Project) -> None: - """Test get with multiple option types combined.""" - # GIVEN a file with activity - file = self.create_file_instance() - file.parent_id = project_model.id - file.activity = self.create_activity() - stored_file = file.store(synapse_client=self.syn) - self.schedule_for_cleanup(stored_file.id) - - # AND combined options - activity_options = ActivityOptions(include_activity=True) - file_options = FileOptions(download_file=False) - - # WHEN I retrieve the file with combined options - retrieved_file = get( - synapse_id=stored_file.id, - activity_options=activity_options, - file_options=file_options, - synapse_client=self.syn, - ) - - # THEN both options are applied - assert isinstance(retrieved_file, File) - assert retrieved_file.id == stored_file.id - assert retrieved_file.download_file is False - assert retrieved_file.activity is not None - assert retrieved_file.activity.name == "Test Activity" - - def test_get_invalid_synapse_id_raises_error(self) -> None: - """Test that get raises appropriate error for invalid Synapse ID.""" - # GIVEN an invalid synapse ID - invalid_id = "syn999999999999" - - # WHEN I try to retrieve the entity - # THEN an appropriate error is raised - with pytest.raises(Exception): # Could be SynapseNotFoundError or similar - get(synapse_id=invalid_id, synapse_client=self.syn) - - def test_get_invalid_entity_name_raises_error(self, project_model: Project) -> None: - """Test that get raises appropriate error for invalid entity name.""" - # GIVEN an invalid entity name - invalid_name = f"nonexistent_entity_{str(uuid.uuid4())}" - - # WHEN I try to retrieve the entity by name - # THEN an appropriate error is raised - with pytest.raises(Exception): # Could be SynapseNotFoundError or similar - get( - entity_name=invalid_name, - parent_id=project_model.id, - synapse_client=self.syn, - ) - - def test_get_validation_errors(self) -> None: - """Test validation errors for invalid parameter combinations.""" - # WHEN I provide both synapse_id and entity_name - # THEN ValueError is raised - with pytest.raises( - ValueError, match="Cannot specify both synapse_id and entity_name" - ): - get( - synapse_id="syn123456", - entity_name="test_entity", - synapse_client=self.syn, - ) - - # WHEN I provide neither synapse_id nor entity_name - # THEN ValueError is raised - with pytest.raises( - ValueError, match="Must specify either synapse_id or entity_name" - ): - get(synapse_client=self.syn) diff --git a/tests/integration/synapseclient/operations/synchronous/test_factory_operations_store.py b/tests/integration/synapseclient/operations/synchronous/test_factory_operations_store.py deleted file mode 100644 index 1b9024727..000000000 --- a/tests/integration/synapseclient/operations/synchronous/test_factory_operations_store.py +++ /dev/null @@ -1,918 +0,0 @@ -"""Integration tests for the synapseclient.operations.store function.""" - -import os -import tempfile -import uuid -from typing import Callable - -import pandas as pd -import pytest - -from synapseclient import Synapse -from synapseclient.api.table_services import ViewTypeMask -from synapseclient.core import utils -from synapseclient.models import ( - Activity, - Column, - ColumnType, - CurationTask, - Dataset, - DatasetCollection, - EntityView, - Evaluation, - File, - Folder, - FormData, - FormGroup, - Grid, - JSONSchema, - Link, - MaterializedView, - Project, - RecordBasedMetadataTaskProperties, - RecordSet, - SchemaOrganization, - SubmissionView, - Table, - Team, - UsedEntity, - UsedURL, - VirtualTable, -) -from synapseclient.operations import ( - StoreContainerOptions, - StoreFileOptions, - StoreGridOptions, - StoreJSONSchemaOptions, - StoreTableOptions, - delete, - store, -) - - -class TestFactoryOperationsStore: - """Tests for the synapseclient.operations.store function.""" - - @pytest.fixture(autouse=True, scope="function") - def init(self, syn: Synapse, schedule_for_cleanup: Callable[..., None]) -> None: - self.syn = syn - self.schedule_for_cleanup = schedule_for_cleanup - - def create_file_instance(self) -> File: - """Helper method to create a test file.""" - filename = utils.make_bogus_uuid_file() - self.schedule_for_cleanup(filename) - return File( - path=filename, - description="Test file for store factory operations", - content_type="text/plain", - name=f"test_file_{str(uuid.uuid4())[:8]}.txt", - ) - - def create_activity(self) -> Activity: - """Helper method to create a test activity.""" - return Activity( - name="Test Activity", - description="Activity for testing store factory operations", - used=[ - UsedURL(name="example", url="https://www.synapse.org/"), - UsedEntity(target_id="syn123456", target_version_number=1), - ], - ) - - def test_store_project_basic(self) -> None: - """Test storing a Project entity.""" - # GIVEN a new project - project = Project( - name=f"test_project_{str(uuid.uuid4())[:8]}", - description="Test project for store factory operations", - ) - - # WHEN I store the project using store - stored_project = store(project, synapse_client=self.syn) - self.schedule_for_cleanup(stored_project.id) - - # THEN the project is created in Synapse - assert stored_project.id is not None - assert stored_project.name == project.name - assert stored_project.description == project.description - assert stored_project.etag is not None - - # WHEN I delete the project using delete - delete(stored_project, synapse_client=self.syn) - - # THEN the project should no longer be retrievable - with pytest.raises(Exception): - Project(id=stored_project.id).get(synapse_client=self.syn) - - def test_store_project_with_container_options(self) -> None: - """Test storing a Project with container options.""" - # GIVEN a new project with container options - project = Project( - name=f"test_project_{str(uuid.uuid4())[:8]}", - description="Test project with container options", - ) - container_options = StoreContainerOptions(failure_strategy="LOG_EXCEPTION") - - # WHEN I store the project with options - stored_project = store( - project, - container_options=container_options, - synapse_client=self.syn, - ) - self.schedule_for_cleanup(stored_project.id) - - # THEN the project is created successfully - assert stored_project.id is not None - assert stored_project.name == project.name - - def test_store_folder_basic(self, project_model: Project) -> None: - """Test storing a Folder entity.""" - # GIVEN a new folder - folder = Folder( - name=f"test_folder_{str(uuid.uuid4())[:8]}", - description="Test folder for store factory operations", - parent_id=project_model.id, - ) - - # WHEN I store the folder using store - stored_folder = store(folder, synapse_client=self.syn) - self.schedule_for_cleanup(stored_folder.id) - - # THEN the folder is created in Synapse with all fields - assert stored_folder.id is not None - assert stored_folder.name == folder.name - assert stored_folder.description == folder.description - assert stored_folder.parent_id == project_model.id - assert stored_folder.etag is not None - - # WHEN I delete the folder using delete - delete(stored_folder, synapse_client=self.syn) - - # THEN the folder should no longer be retrievable - with pytest.raises(Exception): - Folder(id=stored_folder.id).get(synapse_client=self.syn) - - def test_store_folder_with_parent_param(self, project_model: Project) -> None: - """Test storing a Folder with parent parameter.""" - # GIVEN a new folder without parent_id set - folder = Folder( - name=f"test_folder_{str(uuid.uuid4())[:8]}", - description="Test folder with parent parameter", - ) - # Verify parent_id is not set initially - assert folder.parent_id is None - - # WHEN I store the folder with parent parameter - stored_folder = store(folder, parent=project_model, synapse_client=self.syn) - self.schedule_for_cleanup(stored_folder.id) - - # THEN the folder is created with the correct parent - assert stored_folder.id is not None - # Verify the parent parameter was applied - assert stored_folder.parent_id == project_model.id - assert stored_folder.name == folder.name - - def test_store_file_basic(self, project_model: Project) -> None: - """Test storing a File entity.""" - # GIVEN a new file - file = self.create_file_instance() - file.parent_id = project_model.id - - # WHEN I store the file using store - stored_file = store(file, synapse_client=self.syn) - self.schedule_for_cleanup(stored_file.id) - - # THEN the file is uploaded to Synapse with all fields - assert stored_file.id is not None - assert stored_file.name == file.name - assert stored_file.description == file.description - assert stored_file.parent_id == project_model.id - assert stored_file.data_file_handle_id is not None - assert stored_file.version_number == 1 - assert stored_file.etag is not None - - # WHEN I delete the file using delete - delete(stored_file, synapse_client=self.syn) - - # THEN the file should no longer be retrievable (would raise 404) - with pytest.raises(Exception): - File(id=stored_file.id).get(synapse_client=self.syn) - - def test_store_and_delete_file_by_id_string(self, project_model: Project) -> None: - """Test storing a File and deleting it using a string ID.""" - # GIVEN a new file - file = self.create_file_instance() - file.parent_id = project_model.id - - # WHEN I store the file using store - stored_file = store(file, synapse_client=self.syn) - self.schedule_for_cleanup(stored_file.id) - - # THEN the file is uploaded to Synapse - assert stored_file.id is not None - - # WHEN I delete the file using a string ID - delete(stored_file.id, synapse_client=self.syn) - - # THEN the file should no longer be retrievable - with pytest.raises(Exception): - File(id=stored_file.id).get(synapse_client=self.syn) - - def test_store_file_with_file_options(self, project_model: Project) -> None: - """Test storing a File with custom file options.""" - # GIVEN a file with custom options - file = self.create_file_instance() - file.parent_id = project_model.id - file_options = StoreFileOptions( - synapse_store=True, - content_type="application/json", - merge_existing_annotations=True, - ) - - # WHEN I store the file with options - stored_file = store(file, file_options=file_options, synapse_client=self.syn) - self.schedule_for_cleanup(stored_file.id) - - # THEN the file is stored with the specified options - assert stored_file.id is not None - # Verify content_type was applied - assert stored_file.content_type == "application/json" - # Verify file was stored in Synapse (has file handle) - assert stored_file.data_file_handle_id is not None - - def test_store_file_update(self, project_model: Project) -> None: - """Test updating an existing File entity.""" - # GIVEN an existing file - file = self.create_file_instance() - file.parent_id = project_model.id - stored_file = store(file, synapse_client=self.syn) - self.schedule_for_cleanup(stored_file.id) - original_version = stored_file.version_number - - # WHEN I update the file description and store again - stored_file.description = "Updated description" - updated_file = store(stored_file, synapse_client=self.syn) - - # THEN the file is updated - assert updated_file.id == stored_file.id - assert updated_file.description == "Updated description" - # Verify version number incremented - assert updated_file.version_number == original_version + 1 - - def test_store_recordset_basic(self, project_model: Project) -> None: - """Test storing a RecordSet entity.""" - # GIVEN a new recordset - recordset_file = utils.make_bogus_uuid_file() - self.schedule_for_cleanup(recordset_file) - - recordset = RecordSet( - name=f"test_recordset_{str(uuid.uuid4())[:8]}", - description="Test recordset for store factory operations", - parent_id=project_model.id, - path=recordset_file, - upsert_keys=["id"], - ) - - # WHEN I store the recordset using store - stored_recordset = store(recordset, synapse_client=self.syn) - self.schedule_for_cleanup(stored_recordset.id) - - # THEN the recordset is created in Synapse - assert stored_recordset.id is not None - assert stored_recordset.name == recordset.name - assert stored_recordset.description == recordset.description - # Verify RecordSet was stored as a file with data - assert stored_recordset.data_file_handle_id is not None - assert stored_recordset.parent_id == project_model.id - assert stored_recordset.etag is not None - - def test_store_link_basic(self, project_model: Project) -> None: - """Test storing a Link entity.""" - # GIVEN a file to link to - file = self.create_file_instance() - file.parent_id = project_model.id - stored_file = store(file, synapse_client=self.syn) - self.schedule_for_cleanup(stored_file.id) - - # AND a new link - link = Link( - name=f"test_link_{str(uuid.uuid4())[:8]}", - description="Test link for store factory operations", - parent_id=project_model.id, - target_id=stored_file.id, - ) - - # WHEN I store the link using store - stored_link = store(link, synapse_client=self.syn) - self.schedule_for_cleanup(stored_link.id) - - # THEN the link is created in Synapse - assert stored_link.id is not None - assert stored_link.name == link.name - assert stored_link.description == link.description - # Verify link points to the correct target - assert stored_link.target_id == stored_file.id - assert stored_link.parent_id == project_model.id - assert stored_link.etag is not None - - def test_store_table_basic(self, project_model: Project) -> None: - """Test storing a Table entity.""" - # GIVEN a new table - columns = [ - Column(name="col1", column_type=ColumnType.STRING, maximum_size=50), - Column(name="col2", column_type=ColumnType.INTEGER), - ] - table = Table( - name=f"test_table_{str(uuid.uuid4())[:8]}", - description="Test table for store factory operations", - parent_id=project_model.id, - columns=columns, - ) - - # WHEN I store the table using store - stored_table = store(table, synapse_client=self.syn) - self.schedule_for_cleanup(stored_table.id) - - # THEN the table is created with columns - assert stored_table.id is not None - assert stored_table.name == table.name - assert stored_table.description == table.description - assert stored_table.parent_id == project_model.id - assert len(stored_table.columns) == 2 - # Verify column details were preserved (columns is a dict) - assert "col1" in stored_table.columns - assert "col2" in stored_table.columns - assert stored_table.columns["col1"].column_type == ColumnType.STRING - assert stored_table.columns["col2"].column_type == ColumnType.INTEGER - assert stored_table.etag is not None - - # WHEN I delete the table using delete - delete(stored_table, synapse_client=self.syn) - - # THEN the table should no longer be retrievable - with pytest.raises(Exception): - Table(id=stored_table.id).get(synapse_client=self.syn) - - def test_store_table_with_table_options(self, project_model: Project) -> None: - """Test storing a Table with custom table options.""" - # GIVEN a table with options - columns = [ - Column(name="col1", column_type=ColumnType.STRING, maximum_size=50), - ] - table = Table( - name=f"test_table_{str(uuid.uuid4())[:8]}", - description="Test table with options", - parent_id=project_model.id, - columns=columns, - ) - table_options = StoreTableOptions(dry_run=False, job_timeout=600) - - # WHEN I store the table with options - stored_table = store( - table, table_options=table_options, synapse_client=self.syn - ) - self.schedule_for_cleanup(stored_table.id) - - # THEN the table is created (dry_run=False means it persists) - assert stored_table.id is not None - assert stored_table.id.startswith("syn") - # Verify table was actually created with columns (columns is a dict) - assert len(stored_table.columns) == 1 - assert "col1" in stored_table.columns - assert stored_table.columns["col1"].id is not None - - def test_store_dataset_basic(self, project_model: Project) -> None: - """Test storing a Dataset entity.""" - # GIVEN a new dataset - columns = [ - Column(name="itemId", column_type=ColumnType.ENTITYID), - Column(name="name", column_type=ColumnType.STRING, maximum_size=256), - ] - dataset = Dataset( - name=f"test_dataset_{str(uuid.uuid4())[:8]}", - description="Test dataset for store factory operations", - parent_id=project_model.id, - columns=columns, - include_default_columns=False, - ) - - # WHEN I store the dataset using store - stored_dataset = store(dataset, synapse_client=self.syn) - self.schedule_for_cleanup(stored_dataset.id) - - # THEN the dataset is created with all fields - assert stored_dataset.id is not None - assert stored_dataset.name == dataset.name - assert stored_dataset.description == dataset.description - assert stored_dataset.parent_id == project_model.id - assert len(stored_dataset.columns) == 2 - # Verify columns are accessible by name (columns is a dict) - assert "itemId" in stored_dataset.columns - assert "name" in stored_dataset.columns - assert stored_dataset.columns["itemId"].column_type == ColumnType.ENTITYID - assert stored_dataset.columns["name"].column_type == ColumnType.STRING - assert stored_dataset.etag is not None - - # WHEN I delete the dataset using delete - delete(stored_dataset, synapse_client=self.syn) - - # THEN the dataset should no longer be retrievable - with pytest.raises(Exception): - Dataset(id=stored_dataset.id).get(synapse_client=self.syn) - - def test_store_dataset_collection_basic(self, project_model: Project) -> None: - """Test storing a DatasetCollection entity.""" - # GIVEN a new dataset collection - dataset_collection = DatasetCollection( - name=f"test_dataset_collection_{str(uuid.uuid4())[:8]}", - description="Test dataset collection for store factory operations", - parent_id=project_model.id, - include_default_columns=False, - ) - - # WHEN I store the dataset collection using store - stored_collection = store(dataset_collection, synapse_client=self.syn) - self.schedule_for_cleanup(stored_collection.id) - - # THEN the dataset collection is created with all fields - assert stored_collection.id is not None - assert stored_collection.name == dataset_collection.name - assert stored_collection.description == dataset_collection.description - assert stored_collection.parent_id == project_model.id - assert stored_collection.etag is not None - - def test_store_entity_view_basic(self, project_model: Project) -> None: - """Test storing an EntityView entity.""" - # GIVEN a new entity view - columns = [ - Column(name="id", column_type=ColumnType.ENTITYID), - Column(name="name", column_type=ColumnType.STRING, maximum_size=256), - ] - entity_view = EntityView( - name=f"test_entity_view_{str(uuid.uuid4())[:8]}", - description="Test entity view for store factory operations", - parent_id=project_model.id, - columns=columns, - scope_ids=[project_model.id], - view_type_mask=ViewTypeMask.FILE, - include_default_columns=False, - ) - - # WHEN I store the entity view using store - stored_view = store(entity_view, synapse_client=self.syn) - self.schedule_for_cleanup(stored_view.id) - - # THEN the entity view is created with all fields - assert stored_view.id is not None - assert stored_view.name == entity_view.name - assert stored_view.description == entity_view.description - assert stored_view.parent_id == project_model.id - assert len(stored_view.columns) == 2 - # Verify columns are accessible by name (columns is a dict) - assert "id" in stored_view.columns - assert "name" in stored_view.columns - # scope_ids is returned as a set, convert both for comparison - assert set(stored_view.scope_ids) == {project_model.id} - assert stored_view.view_type_mask == ViewTypeMask.FILE - assert stored_view.etag is not None - - def test_store_submission_view_basic(self, project_model: Project) -> None: - """Test storing a SubmissionView entity.""" - # GIVEN a new submission view - columns = [ - Column(name="id", column_type=ColumnType.SUBMISSIONID), - Column(name="name", column_type=ColumnType.STRING, maximum_size=256), - ] - submission_view = SubmissionView( - name=f"test_submission_view_{str(uuid.uuid4())[:8]}", - description="Test submission view for store factory operations", - parent_id=project_model.id, - columns=columns, - scope_ids=[project_model.id], - include_default_columns=False, - ) - - # WHEN I store the submission view using store - stored_view = store(submission_view, synapse_client=self.syn) - self.schedule_for_cleanup(stored_view.id) - - # THEN the submission view is created with all fields - assert stored_view.id is not None - assert stored_view.name == submission_view.name - assert stored_view.description == submission_view.description - assert stored_view.parent_id == project_model.id - assert len(stored_view.columns) == 2 - # Verify columns are accessible by name (columns is a dict) - assert "id" in stored_view.columns - assert "name" in stored_view.columns - assert stored_view.columns["id"].column_type == ColumnType.SUBMISSIONID - # scope_ids may have numeric strings without 'syn' prefix - assert len(stored_view.scope_ids) == 1 - # Check if it matches with or without the 'syn' prefix - scope_id = stored_view.scope_ids[0] - assert scope_id == project_model.id or scope_id == project_model.id.replace( - "syn", "" - ) - assert stored_view.etag is not None - - def test_store_materialized_view_basic(self, project_model: Project) -> None: - """Test storing a MaterializedView entity.""" - # GIVEN a source table for the materialized view - columns = [ - Column(name="col1", column_type=ColumnType.STRING, maximum_size=50), - ] - source_table = Table( - name=f"source_table_{str(uuid.uuid4())[:8]}", - parent_id=project_model.id, - columns=columns, - ) - stored_source = store(source_table, synapse_client=self.syn) - self.schedule_for_cleanup(stored_source.id) - - # AND a new materialized view - materialized_view = MaterializedView( - name=f"test_materialized_view_{str(uuid.uuid4())[:8]}", - description="Test materialized view for store factory operations", - parent_id=project_model.id, - defining_sql=f"SELECT * FROM {stored_source.id}", - ) - - # WHEN I store the materialized view using store - stored_view = store(materialized_view, synapse_client=self.syn) - self.schedule_for_cleanup(stored_view.id) - - # THEN the materialized view is created with all fields - assert stored_view.id is not None - assert stored_view.name == materialized_view.name - assert stored_view.description == materialized_view.description - assert stored_view.parent_id == project_model.id - assert stored_view.defining_sql == materialized_view.defining_sql - assert stored_view.etag is not None - - def test_store_virtual_table_basic(self, project_model: Project) -> None: - """Test storing a VirtualTable entity.""" - # GIVEN a source table for the virtual table - columns = [ - Column(name="col1", column_type=ColumnType.STRING, maximum_size=50), - ] - source_table = Table( - name=f"source_table_{str(uuid.uuid4())[:8]}", - parent_id=project_model.id, - columns=columns, - ) - stored_source = store(source_table, synapse_client=self.syn) - self.schedule_for_cleanup(stored_source.id) - - # AND a new virtual table - virtual_table = VirtualTable( - name=f"test_virtual_table_{str(uuid.uuid4())[:8]}", - description="Test virtual table for store factory operations", - parent_id=project_model.id, - defining_sql=f"SELECT * FROM {stored_source.id}", - ) - - # WHEN I store the virtual table using store - stored_virtual = store(virtual_table, synapse_client=self.syn) - self.schedule_for_cleanup(stored_virtual.id) - - # THEN the virtual table is created with all fields - assert stored_virtual.id is not None - assert stored_virtual.name == virtual_table.name - assert stored_virtual.description == virtual_table.description - assert stored_virtual.parent_id == project_model.id - assert stored_virtual.defining_sql == virtual_table.defining_sql - assert stored_virtual.etag is not None - - def test_store_evaluation_basic(self, project_model: Project) -> None: - """Test storing an Evaluation entity.""" - # GIVEN a new evaluation - evaluation = Evaluation( - name=f"test_evaluation_{str(uuid.uuid4())[:8]}", - description="Test evaluation for store factory operations", - content_source=project_model.id, - submission_instructions_message="Instructions for submission", - submission_receipt_message="Thank you for your submission", - ) - - # WHEN I store the evaluation using store - stored_evaluation = store(evaluation, synapse_client=self.syn) - self.schedule_for_cleanup(stored_evaluation.id) - - # THEN the evaluation is created - assert stored_evaluation.id is not None - assert stored_evaluation.name == evaluation.name - # Verify evaluation properties were preserved - assert stored_evaluation.description == evaluation.description - assert stored_evaluation.content_source == project_model.id - - def test_store_team_basic(self) -> None: - """Test storing a Team entity.""" - # GIVEN a new team - team = Team( - name=f"test_team_{str(uuid.uuid4())[:8]}", - description="Test team for store factory operations", - ) - - # WHEN I store the team using store - stored_team = store(team, synapse_client=self.syn) - self.schedule_for_cleanup(stored_team.id) - - # THEN the team is created with all fields - assert stored_team.id is not None - assert stored_team.name == team.name - assert stored_team.description == team.description - assert stored_team.etag is not None - - # WHEN I delete the team using delete - delete(stored_team, synapse_client=self.syn) - - # THEN the team should no longer be retrievable - with pytest.raises(Exception): - Team(id=stored_team.id).get(synapse_client=self.syn) - - def test_store_curation_task_basic(self, project_model: Project) -> None: - """Test storing a CurationTask entity.""" - # GIVEN a folder for the curation task - folder = Folder( - name=f"test_folder_{str(uuid.uuid4())[:8]}", - parent_id=project_model.id, - ).store(synapse_client=self.syn) - self.schedule_for_cleanup(folder.id) - - # AND a RecordSet - test_data = pd.DataFrame({"col1": [1, 2], "col2": ["a", "b"]}) - temp_fd, filename = tempfile.mkstemp(suffix=".csv") - try: - os.close(temp_fd) - test_data.to_csv(filename, index=False) - self.schedule_for_cleanup(filename) - - record_set = RecordSet( - name=f"test_recordset_{str(uuid.uuid4())[:8]}", - parent_id=folder.id, - path=filename, - upsert_keys=["col1"], - ).store(synapse_client=self.syn) - self.schedule_for_cleanup(record_set.id) - except Exception: - if os.path.exists(filename): - os.unlink(filename) - raise - - # AND task properties - task_properties = RecordBasedMetadataTaskProperties( - record_set_id=record_set.id, - ) - - # AND a new curation task - curation_task = CurationTask( - project_id=project_model.id, - data_type="test_data_type", - instructions="Test instructions for curation task", - task_properties=task_properties, - ) - - # WHEN I store the curation task using store - stored_task = store(curation_task, synapse_client=self.syn) - - # THEN the curation task is created with all fields - assert stored_task.task_id is not None - assert stored_task.project_id == project_model.id - assert stored_task.instructions == "Test instructions for curation task" - assert stored_task.etag is not None - - def test_store_schema_organization_basic(self) -> None: - """Test storing a SchemaOrganization entity.""" - # GIVEN a new schema organization - # Name must have each part start with a letter - schema_org = SchemaOrganization( - name=f"test.schema.org.test{str(uuid.uuid4())[:8]}", - ) - - # WHEN I store the schema organization using store - stored_org = store(schema_org, synapse_client=self.syn) - self.schedule_for_cleanup(stored_org.id) - - # THEN the schema organization is created with all fields - assert stored_org.id is not None - assert stored_org.name == schema_org.name - assert stored_org.created_on is not None - - # WHEN I delete the schema organization using delete - delete(stored_org, synapse_client=self.syn) - - # THEN the schema organization should no longer be retrievable - with pytest.raises(Exception): - SchemaOrganization(organization_name=stored_org.name).get( - synapse_client=self.syn - ) - - def test_store_unsupported_entity_raises_error(self) -> None: - """Test that storing an unsupported entity type raises an error.""" - # GIVEN an unsupported entity type (using a dict as a proxy) - invalid_entity = {"type": "InvalidEntity"} - - # WHEN/THEN storing the invalid entity raises ValueError - with pytest.raises(ValueError, match="Unsupported entity type"): - store(invalid_entity, synapse_client=self.syn) - - def test_store_file_with_activity(self, project_model: Project) -> None: - """Test storing a File with activity.""" - # GIVEN a file with activity - file = self.create_file_instance() - file.parent_id = project_model.id - activity = self.create_activity() - file.activity = activity - - # WHEN I store the file - stored_file = store(file, synapse_client=self.syn) - self.schedule_for_cleanup(stored_file.id) - - # THEN the file is stored with activity - assert stored_file.id is not None - assert stored_file.activity is not None - # Verify activity details were preserved - assert stored_file.activity.name == activity.name - assert stored_file.activity.description == activity.description - assert len(stored_file.activity.used) == 2 - - def test_store_table_dry_run(self, project_model: Project) -> None: - """Test storing a Table with dry_run option.""" - # GIVEN a table and dry_run options - columns = [ - Column(name="col1", column_type=ColumnType.STRING, maximum_size=50), - ] - table = Table( - name=f"test_table_{str(uuid.uuid4())[:8]}", - description="Test table for dry run", - parent_id=project_model.id, - columns=columns, - ) - table_options = StoreTableOptions(dry_run=True) - - # WHEN I store with dry_run=True - result = store(table, table_options=table_options, synapse_client=self.syn) - - # THEN the result is returned but no actual entity is created - # dry_run validates the table structure without persisting - assert result is not None - # In dry run mode, the table should either have no ID or the same ID if updating - # The key is that it doesn't create a new permanent entity - - def test_store_container_with_raise_exception_strategy( - self, project_model: Project - ) -> None: - """Test storing a container with RAISE_EXCEPTION failure strategy.""" - # GIVEN a folder with failure strategy option - folder = Folder( - name=f"test_folder_{str(uuid.uuid4())[:8]}", - description="Test folder with raise exception", - parent_id=project_model.id, - ) - container_options = StoreContainerOptions(failure_strategy="RAISE_EXCEPTION") - - # WHEN I store the folder with RAISE_EXCEPTION - stored_folder = store( - folder, - container_options=container_options, - synapse_client=self.syn, - ) - self.schedule_for_cleanup(stored_folder.id) - - # THEN the folder is stored successfully - assert stored_folder.id is not None - - def test_store_form_group_basic(self, project_model: Project) -> None: - """Test storing a FormGroup entity.""" - # GIVEN a new form group - form_group = FormGroup( - name=f"test_form_group_{str(uuid.uuid4())[:8]}", - ) - - # WHEN I store the form group using store - stored_form_group = store(form_group, synapse_client=self.syn) - - # THEN the form group is created - assert stored_form_group.group_id is not None - assert stored_form_group.name == form_group.name - - def test_store_form_data_basic(self, project_model: Project) -> None: - """Test storing a FormData entity.""" - # GIVEN a form group first - form_group = FormGroup( - name=f"test_form_group_{str(uuid.uuid4())[:8]}", - ) - stored_form_group = store(form_group, synapse_client=self.syn) - - # AND a file to get a file handle ID - file = self.create_file_instance() - file.parent_id = project_model.id - stored_file = store(file, synapse_client=self.syn) - self.schedule_for_cleanup(stored_file.id) - - # AND form data using the file handle - form_data = FormData( - group_id=stored_form_group.group_id, - name=f"test_form_data_{str(uuid.uuid4())[:8]}", - data_file_handle_id=stored_file.data_file_handle_id, - ) - - # WHEN I store the form data using store - stored_form_data = store(form_data, synapse_client=self.syn) - - # THEN the form data is created - assert stored_form_data.form_data_id is not None - assert stored_form_data.group_id == stored_form_group.group_id - assert stored_form_data.name == form_data.name - - def test_store_json_schema_basic(self) -> None: - """Test storing a JSONSchema entity.""" - # GIVEN a schema organization first - schema_org = SchemaOrganization( - name=f"test.schema.org.test{str(uuid.uuid4())[:8]}", - ) - stored_org = store(schema_org, synapse_client=self.syn) - self.schedule_for_cleanup(stored_org.id) - - # AND a JSON schema - json_schema = JSONSchema( - organization_name=stored_org.name, - name="TestSchema", - ) - schema_body = { - "$schema": "http://json-schema.org/draft-07/schema#", - "type": "object", - "properties": { - "name": {"type": "string"}, - "age": {"type": "integer"}, - }, - } - json_schema_options = StoreJSONSchemaOptions( - schema_body=schema_body, - version="1.0.0", - ) - - # WHEN I store the JSON schema using store - stored_schema = store( - json_schema, - json_schema_options=json_schema_options, - synapse_client=self.syn, - ) - - # THEN the JSON schema is created - assert stored_schema.created_on is not None - assert stored_schema.organization_name == stored_org.name - assert stored_schema.name == "TestSchema" - - # WHEN I delete the JSON schema using delete - delete(stored_schema, synapse_client=self.syn) - - # THEN the JSON schema should no longer be retrievable - with pytest.raises(Exception): - JSONSchema( - organization_name=stored_schema.organization_name, - name=stored_schema.name, - ).get(synapse_client=self.syn) - - # Clean up the schema organization - delete(stored_org, synapse_client=self.syn) - - def test_store_grid_basic(self, project_model: Project) -> None: - """Test storing a Grid entity.""" - # GIVEN a RecordSet first - recordset_file = utils.make_bogus_uuid_file() - self.schedule_for_cleanup(recordset_file) - - recordset = RecordSet( - name=f"test_recordset_{str(uuid.uuid4())[:8]}", - parent_id=project_model.id, - path=recordset_file, - upsert_keys=["id"], - ) - stored_recordset = store(recordset, synapse_client=self.syn) - self.schedule_for_cleanup(stored_recordset.id) - - # AND a Grid - grid = Grid( - record_set_id=stored_recordset.id, - ) - grid_options = StoreGridOptions( - attach_to_previous_session=False, - timeout=120, - ) - - # WHEN I store the grid using store - stored_grid = store( - grid, - grid_options=grid_options, - synapse_client=self.syn, - ) - - # THEN the grid is created - assert stored_grid.session_id is not None - assert stored_grid.record_set_id == stored_recordset.id - - # WHEN I delete the grid using delete - delete(stored_grid, synapse_client=self.syn) - # Grid deletion is fire-and-forget, no need to verify diff --git a/tests/integration/synapseclient/operations/synchronous/test_utility_operations.py b/tests/integration/synapseclient/operations/synchronous/test_utility_operations.py deleted file mode 100644 index 00de33128..000000000 --- a/tests/integration/synapseclient/operations/synchronous/test_utility_operations.py +++ /dev/null @@ -1,223 +0,0 @@ -"""Integration tests for utility operations synchronous.""" -import uuid -from typing import Callable - -import pytest - -from synapseclient import Synapse -from synapseclient.core import utils -from synapseclient.models import File, Folder, Project -from synapseclient.operations import find_entity_id, is_synapse_id, md5_query, onweb - - -class TestUtilityOperations: - """Tests for the utility factory functions (synchronous).""" - - @pytest.fixture(autouse=True, scope="function") - def init(self, syn: Synapse, schedule_for_cleanup: Callable[..., None]) -> None: - self.syn = syn - self.schedule_for_cleanup = schedule_for_cleanup - - def test_find_entity_id_project_by_name(self, project_model: Project) -> None: - """Test finding a project by name.""" - # GIVEN a project exists - project_name = project_model.name - - # WHEN I search for the project by name - found_id = find_entity_id(name=project_name, synapse_client=self.syn) - - # THEN I expect to find the correct project ID - assert found_id is not None - assert found_id == project_model.id - - def test_find_entity_id_project_by_name_not_found(self) -> None: - """Test finding a project that doesn't exist.""" - # GIVEN a project name that doesn't exist - fake_project_name = f"NonExistentProject_{uuid.uuid4()}" - - # WHEN I search for the project by name - found_id = find_entity_id(name=fake_project_name, synapse_client=self.syn) - - # THEN I expect None to be returned - assert found_id is None - - def test_find_entity_id_file_by_name_in_parent( - self, project_model: Project - ) -> None: - """Test finding a file by name within a parent folder.""" - # GIVEN a file stored in a project - filename = utils.make_bogus_uuid_file() - self.schedule_for_cleanup(filename) - - file_name = f"test_file_{str(uuid.uuid4())[:8]}.txt" - file = File( - path=filename, - parent_id=project_model.id, - name=file_name, - description="Test file for find_entity_id", - ) - file = file.store(synapse_client=self.syn) - self.schedule_for_cleanup(file.id) - assert file.id is not None - - # WHEN I search for the file by name and parent - found_id = find_entity_id( - name=file_name, parent=project_model.id, synapse_client=self.syn - ) - - # THEN I expect to find the correct file ID - assert found_id is not None - assert found_id == file.id - - def test_find_entity_id_file_not_found_in_parent( - self, project_model: Project - ) -> None: - """Test finding a file that doesn't exist in parent.""" - # GIVEN a file name that doesn't exist - fake_file_name = f"nonexistent_{uuid.uuid4()}.txt" - - # WHEN I search for the file by name and parent - found_id = find_entity_id( - name=fake_file_name, parent=project_model.id, synapse_client=self.syn - ) - - # THEN I expect None to be returned - assert found_id is None - - def test_find_entity_id_with_parent_object(self, project_model: Project) -> None: - """Test finding an entity using a parent object instead of ID.""" - # GIVEN a folder in a project - folder = Folder( - name=f"test_folder_{str(uuid.uuid4())[:8]}", - parent_id=project_model.id, - ) - folder = folder.store(synapse_client=self.syn) - self.schedule_for_cleanup(folder.id) - - # WHEN I search for the folder by name using parent object - found_id = find_entity_id( - name=folder.name, parent=project_model, synapse_client=self.syn - ) - - # THEN I expect to find the correct folder ID - assert found_id is not None - assert found_id == folder.id - - def test_is_synapse_id_valid(self, project_model: Project) -> None: - """Test checking if a valid Synapse ID exists.""" - # GIVEN a valid project ID - project_id = project_model.id - - # WHEN I check if the ID is valid - is_valid = is_synapse_id(project_id, synapse_client=self.syn) - - # THEN I expect it to be valid - assert is_valid is True - - def test_is_synapse_id_invalid(self) -> None: - """Test checking an invalid Synapse ID.""" - # GIVEN an invalid Synapse ID - invalid_id = "syn999999999999999" - - # WHEN I check if the ID is valid - is_valid = is_synapse_id(invalid_id, synapse_client=self.syn) - - # THEN I expect it to be invalid - assert is_valid is False - - def test_is_synapse_id_not_string(self) -> None: - """Test checking a non-string value.""" - # GIVEN a non-string value - not_string = 123456 - - # WHEN I check if it's a valid Synapse ID - is_valid = is_synapse_id(not_string, synapse_client=self.syn) - - # THEN I expect it to be invalid - assert is_valid is False - - def test_md5_query_file(self, project_model: Project) -> None: - """Test finding entities by MD5 hash.""" - # GIVEN a file stored in synapse - filename = utils.make_bogus_uuid_file() - self.schedule_for_cleanup(filename) - - file = File( - path=filename, - parent_id=project_model.id, - name=f"test_file_{str(uuid.uuid4())[:8]}.txt", - description="Test file for MD5 query", - ) - file = file.store(synapse_client=self.syn) - self.schedule_for_cleanup(file.id) - assert file.id is not None - assert file.file_handle is not None - assert file.file_handle.content_md5 is not None - - # WHEN I query for entities with this MD5 - md5_hash = file.file_handle.content_md5 - results = md5_query(md5_hash, synapse_client=self.syn) - - # THEN I expect to find at least this file in the results - assert isinstance(results, list) - assert len(results) > 0 - found_ids = [result["id"] for result in results] - assert file.id in found_ids - - def test_md5_query_nonexistent(self) -> None: - """Test querying for a nonexistent MD5 hash.""" - # GIVEN a fake MD5 hash that doesn't exist - fake_md5 = "00000000000000000000000000000000" - - # WHEN I query for entities with this MD5 - results = md5_query(fake_md5, synapse_client=self.syn) - - # THEN I expect an empty list - assert isinstance(results, list) - assert len(results) == 0 - - def test_onweb_project_by_id(self, project_model: Project) -> None: - """Test opening a project in web browser by ID.""" - # GIVEN a project exists - project_id = project_model.id - - # WHEN I call onweb with the project ID - url = onweb(project_id, synapse_client=self.syn) - - # THEN I expect a valid Synapse URL to be returned - assert url is not None - assert isinstance(url, str) - assert "synapse.org" in url.lower() - assert project_id in url - assert "Synapse:" in url - - def test_onweb_project_by_object(self, project_model: Project) -> None: - """Test opening a project in web browser by object.""" - # GIVEN a project exists - # WHEN I call onweb with the project object - url = onweb(project_model, synapse_client=self.syn) - - # THEN I expect a valid Synapse URL to be returned - assert url is not None - assert isinstance(url, str) - assert "synapse.org" in url.lower() - assert project_model.id in url - assert "Synapse:" in url - - def test_onweb_with_subpage(self, project_model: Project) -> None: - """Test opening a wiki subpage in web browser.""" - # GIVEN a project exists and a subpage ID - project_id = project_model.id - subpage_id = "12345" - - # WHEN I call onweb with subpage_id - url = onweb(project_id, subpage_id=subpage_id, synapse_client=self.syn) - - # THEN I expect a valid Synapse URL with wiki reference - assert url is not None - assert isinstance(url, str) - assert "synapse.org" in url.lower() - assert project_id in url - assert subpage_id in url - assert "Wiki:" in url - assert "/ENTITY/" in url diff --git a/tests/integration/synapseclient/test_evaluations.py b/tests/integration/synapseclient/test_evaluations.py index 931e25771..e8d0c4b17 100644 --- a/tests/integration/synapseclient/test_evaluations.py +++ b/tests/integration/synapseclient/test_evaluations.py @@ -13,13 +13,14 @@ # @skip("Skip integration tests for soon to be removed code") -def test_evaluations(syn: Synapse, project: Project): +def test_evaluations(syn: Synapse, project: Project, schedule_for_cleanup): # Create an Evaluation name = "Test Evaluation %s" % str(uuid.uuid4()) ev = Evaluation( name=name, description="Evaluation for testing", contentSource=project["id"] ) ev = syn.store(ev) + schedule_for_cleanup(ev) try: # -- Get the Evaluation by name diff --git a/tests/integration/synapseclient/test_json_schema_services.py b/tests/integration/synapseclient/test_json_schema_services.py index dcad56af1..caad29ba9 100644 --- a/tests/integration/synapseclient/test_json_schema_services.py +++ b/tests/integration/synapseclient/test_json_schema_services.py @@ -150,7 +150,7 @@ def test_json_schema_validate(self, js, syn, schedule_for_cleanup): self.simple_schema, self.schema_name, f"0.{self.rint}.1" ) js.bind_json_schema(new_version.uri, synapse_id) - sleep(3) + sleep(1) # TODO: look into why this doesn't work # js.validate(synapse_id) js.validate_children(synapse_id) diff --git a/tests/integration/synapseclient/test_tables.py b/tests/integration/synapseclient/test_tables.py index b50a718bd..7cc560177 100644 --- a/tests/integration/synapseclient/test_tables.py +++ b/tests/integration/synapseclient/test_tables.py @@ -16,7 +16,6 @@ import synapseclient.core.utils as utils from synapseclient import ( Column, - Dataset, EntityViewSchema, EntityViewType, File, @@ -156,6 +155,7 @@ def test_create_and_update_file_view( while new_view_dict[0]["fileFormat"] != "PNG": # check timeout assert time.time() - start_time < QUERY_TIMEOUT_SEC + time.sleep(1) # backoff between retries # query again new_view_results = syn.tableQuery("select * from %s" % entity_view.id) new_view_dict = list( @@ -741,7 +741,9 @@ def _query_with_retry( return query_results except AssertionError: # hasn't found the result yet - pass + time.sleep(1) # backoff between retries elif expected_result_len and len(query_results) == expected_result_len: return query_results + else: + time.sleep(1) # backoff between retries return None diff --git a/tests/unit/synapseclient/api/unit_test_evaluation_services.py b/tests/unit/synapseclient/api/unit_test_evaluation_services.py new file mode 100644 index 000000000..ab2c67ed4 --- /dev/null +++ b/tests/unit/synapseclient/api/unit_test_evaluation_services.py @@ -0,0 +1,664 @@ +"""Unit tests for evaluation_services utility functions.""" + +import json +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +import synapseclient.api.evaluation_services as evaluation_services + +EVALUATION_ID = "9614112" +EVALUATION_NAME = "My Test Evaluation" +PROJECT_ID = "syn123456" +SUBMISSION_ID = "12345" +ETAG = "abc-def-ghi" +BATCH_TOKEN = "batch-token-123" + + +class TestCreateOrUpdateEvaluation: + """Tests for create_or_update_evaluation function.""" + + @patch("synapseclient.Synapse") + async def test_create_evaluation(self, mock_synapse): + """Test creating a new evaluation (no id in request body).""" + # GIVEN a mock client that returns a created evaluation + mock_client = AsyncMock() + mock_client.logger = MagicMock() + mock_synapse.get_client.return_value = mock_client + request_body = {"name": EVALUATION_NAME, "contentSource": PROJECT_ID} + expected_response = { + "id": EVALUATION_ID, + "name": EVALUATION_NAME, + "contentSource": PROJECT_ID, + } + mock_client.rest_post_async.return_value = expected_response + + # WHEN I call create_or_update_evaluation without an id + result = await evaluation_services.create_or_update_evaluation( + request_body=request_body, synapse_client=None + ) + + # THEN I expect a POST to /evaluation + assert result == expected_response + mock_client.rest_post_async.assert_awaited_once_with( + "/evaluation", body=json.dumps(request_body) + ) + + @patch("synapseclient.Synapse") + async def test_update_evaluation(self, mock_synapse): + """Test updating an existing evaluation (id present in request body).""" + # GIVEN a mock client that returns an updated evaluation + mock_client = AsyncMock() + mock_client.logger = MagicMock() + mock_synapse.get_client.return_value = mock_client + request_body = { + "id": EVALUATION_ID, + "name": EVALUATION_NAME, + "contentSource": PROJECT_ID, + } + expected_response = dict(request_body) + mock_client.rest_put_async.return_value = expected_response + + # WHEN I call create_or_update_evaluation with an id + result = await evaluation_services.create_or_update_evaluation( + request_body=request_body, synapse_client=None + ) + + # THEN I expect a PUT to /evaluation/{id} + assert result == expected_response + mock_client.rest_put_async.assert_awaited_once_with( + f"/evaluation/{EVALUATION_ID}", body=json.dumps(request_body) + ) + + +class TestGetEvaluation: + """Tests for get_evaluation function.""" + + @patch("synapseclient.Synapse") + async def test_get_evaluation_by_id(self, mock_synapse): + """Test getting an evaluation by ID.""" + # GIVEN a mock client that returns an evaluation + mock_client = AsyncMock() + mock_synapse.get_client.return_value = mock_client + expected_response = {"id": EVALUATION_ID, "name": EVALUATION_NAME} + mock_client.rest_get_async.return_value = expected_response + + # WHEN I call get_evaluation with an evaluation_id + result = await evaluation_services.get_evaluation( + evaluation_id=EVALUATION_ID, synapse_client=None + ) + + # THEN I expect a GET to /evaluation/{id} + assert result == expected_response + mock_client.rest_get_async.assert_awaited_once_with( + f"/evaluation/{EVALUATION_ID}" + ) + + @patch("synapseclient.Synapse") + async def test_get_evaluation_by_name(self, mock_synapse): + """Test getting an evaluation by name.""" + # GIVEN a mock client that returns an evaluation + mock_client = AsyncMock() + mock_synapse.get_client.return_value = mock_client + expected_response = {"id": EVALUATION_ID, "name": EVALUATION_NAME} + mock_client.rest_get_async.return_value = expected_response + + # WHEN I call get_evaluation with a name + result = await evaluation_services.get_evaluation( + name=EVALUATION_NAME, synapse_client=None + ) + + # THEN I expect a GET to /evaluation/name/{name} + assert result == expected_response + mock_client.rest_get_async.assert_awaited_once_with( + f"/evaluation/name/{EVALUATION_NAME}" + ) + + @patch("synapseclient.Synapse") + async def test_get_evaluation_no_id_or_name_raises(self, mock_synapse): + """Test that ValueError is raised when neither id nor name is provided.""" + # GIVEN a mock client + mock_client = AsyncMock() + mock_synapse.get_client.return_value = mock_client + + # WHEN I call get_evaluation without id or name + # THEN I expect a ValueError + with pytest.raises( + ValueError, match="Either 'evaluation_id' or 'name' must be provided" + ): + await evaluation_services.get_evaluation(synapse_client=None) + + +class TestGetEvaluationsByProject: + """Tests for get_evaluations_by_project function.""" + + @patch("synapseclient.Synapse") + async def test_get_evaluations_by_project_minimal(self, mock_synapse): + """Test getting evaluations by project with minimal params.""" + # GIVEN a mock client that returns a list of evaluations + mock_client = AsyncMock() + mock_synapse.get_client.return_value = mock_client + expected_response = [{"id": EVALUATION_ID, "name": EVALUATION_NAME}] + mock_client.rest_get_async.return_value = expected_response + + # WHEN I call get_evaluations_by_project with only project_id + result = await evaluation_services.get_evaluations_by_project( + project_id=PROJECT_ID, synapse_client=None + ) + + # THEN I expect a GET to /entity/{project_id}/evaluation with no extra params + assert result == expected_response + mock_client.rest_get_async.assert_awaited_once_with( + f"/entity/{PROJECT_ID}/evaluation", params={} + ) + + @patch("synapseclient.Synapse") + async def test_get_evaluations_by_project_all_params(self, mock_synapse): + """Test getting evaluations by project with all optional params.""" + # GIVEN a mock client that returns evaluations + mock_client = AsyncMock() + mock_synapse.get_client.return_value = mock_client + expected_response = [{"id": EVALUATION_ID}] + mock_client.rest_get_async.return_value = expected_response + + # WHEN I call get_evaluations_by_project with all optional params + result = await evaluation_services.get_evaluations_by_project( + project_id=PROJECT_ID, + access_type="READ", + active_only=True, + evaluation_ids=["111", "222"], + offset=5, + limit=20, + synapse_client=None, + ) + + # THEN I expect a GET with all params populated + assert result == expected_response + mock_client.rest_get_async.assert_awaited_once_with( + f"/entity/{PROJECT_ID}/evaluation", + params={ + "accessType": "READ", + "activeOnly": "true", + "evaluationIds": "111,222", + "offset": 5, + "limit": 20, + }, + ) + + +class TestGetAllEvaluations: + """Tests for get_all_evaluations function.""" + + @patch("synapseclient.Synapse") + async def test_get_all_evaluations_minimal(self, mock_synapse): + """Test getting all evaluations with no optional params.""" + # GIVEN a mock client that returns evaluations + mock_client = AsyncMock() + mock_synapse.get_client.return_value = mock_client + expected_response = [{"id": EVALUATION_ID}] + mock_client.rest_get_async.return_value = expected_response + + # WHEN I call get_all_evaluations with no optional params + result = await evaluation_services.get_all_evaluations(synapse_client=None) + + # THEN I expect a GET to /evaluation + assert result == expected_response + mock_client.rest_get_async.assert_awaited_once_with("/evaluation", params={}) + + @patch("synapseclient.Synapse") + async def test_get_all_evaluations_with_params(self, mock_synapse): + """Test getting all evaluations with all optional params.""" + # GIVEN a mock client + mock_client = AsyncMock() + mock_synapse.get_client.return_value = mock_client + expected_response = [{"id": EVALUATION_ID}] + mock_client.rest_get_async.return_value = expected_response + + # WHEN I call get_all_evaluations with optional params + result = await evaluation_services.get_all_evaluations( + access_type="SUBMIT", + active_only=False, + evaluation_ids=["333"], + offset=0, + limit=10, + synapse_client=None, + ) + + # THEN I expect params to be correctly built + assert result == expected_response + mock_client.rest_get_async.assert_awaited_once_with( + "/evaluation", + params={ + "accessType": "SUBMIT", + "activeOnly": "false", + "evaluationIds": "333", + "offset": 0, + "limit": 10, + }, + ) + + +class TestGetAvailableEvaluations: + """Tests for get_available_evaluations function.""" + + @patch("synapseclient.Synapse") + async def test_get_available_evaluations_minimal(self, mock_synapse): + """Test getting available evaluations with no optional params.""" + # GIVEN a mock client + mock_client = AsyncMock() + mock_synapse.get_client.return_value = mock_client + expected_response = [{"id": EVALUATION_ID}] + mock_client.rest_get_async.return_value = expected_response + + # WHEN I call get_available_evaluations + result = await evaluation_services.get_available_evaluations( + synapse_client=None + ) + + # THEN I expect a GET to /evaluation/available + assert result == expected_response + mock_client.rest_get_async.assert_awaited_once_with( + "/evaluation/available", params={} + ) + + @patch("synapseclient.Synapse") + async def test_get_available_evaluations_with_params(self, mock_synapse): + """Test getting available evaluations with all optional params.""" + # GIVEN a mock client + mock_client = AsyncMock() + mock_synapse.get_client.return_value = mock_client + expected_response = [] + mock_client.rest_get_async.return_value = expected_response + + # WHEN I call get_available_evaluations with params + result = await evaluation_services.get_available_evaluations( + active_only=True, + evaluation_ids=["444", "555"], + offset=10, + limit=5, + synapse_client=None, + ) + + # THEN I expect all params in the request + assert result == expected_response + mock_client.rest_get_async.assert_awaited_once_with( + "/evaluation/available", + params={ + "activeOnly": "true", + "evaluationIds": "444,555", + "offset": 10, + "limit": 5, + }, + ) + + +class TestDeleteEvaluation: + """Tests for delete_evaluation function.""" + + @patch("synapseclient.Synapse") + async def test_delete_evaluation(self, mock_synapse): + """Test deleting an evaluation.""" + # GIVEN a mock client + mock_client = AsyncMock() + mock_synapse.get_client.return_value = mock_client + + # WHEN I call delete_evaluation + await evaluation_services.delete_evaluation( + evaluation_id=EVALUATION_ID, synapse_client=None + ) + + # THEN I expect a DELETE to /evaluation/{id} + mock_client.rest_delete_async.assert_awaited_once_with( + f"/evaluation/{EVALUATION_ID}" + ) + + +class TestGetEvaluationAcl: + """Tests for get_evaluation_acl function.""" + + @patch("synapseclient.Synapse") + async def test_get_evaluation_acl(self, mock_synapse): + """Test getting evaluation ACL.""" + # GIVEN a mock client that returns an ACL + mock_client = AsyncMock() + mock_synapse.get_client.return_value = mock_client + expected_acl = { + "id": EVALUATION_ID, + "resourceAccess": [{"principalId": 123, "accessType": ["READ"]}], + } + mock_client.rest_get_async.return_value = expected_acl + + # WHEN I call get_evaluation_acl + result = await evaluation_services.get_evaluation_acl( + evaluation_id=EVALUATION_ID, synapse_client=None + ) + + # THEN I expect a GET to /evaluation/{id}/acl + assert result == expected_acl + mock_client.rest_get_async.assert_awaited_once_with( + f"/evaluation/{EVALUATION_ID}/acl" + ) + + +class TestUpdateEvaluationAcl: + """Tests for update_evaluation_acl function.""" + + @patch("synapseclient.Synapse") + async def test_update_evaluation_acl(self, mock_synapse): + """Test updating evaluation ACL.""" + # GIVEN a mock client that returns the updated ACL + mock_client = AsyncMock() + mock_synapse.get_client.return_value = mock_client + acl = { + "id": EVALUATION_ID, + "resourceAccess": [{"principalId": 123, "accessType": ["READ", "UPDATE"]}], + } + mock_client.rest_put_async.return_value = acl + + # WHEN I call update_evaluation_acl + result = await evaluation_services.update_evaluation_acl( + acl=acl, synapse_client=None + ) + + # THEN I expect a PUT to /evaluation/acl + assert result == acl + mock_client.rest_put_async.assert_awaited_once_with( + "/evaluation/acl", body=json.dumps(acl) + ) + + +class TestGetEvaluationPermissions: + """Tests for get_evaluation_permissions function.""" + + @patch("synapseclient.Synapse") + async def test_get_evaluation_permissions(self, mock_synapse): + """Test getting evaluation permissions.""" + # GIVEN a mock client that returns permissions + mock_client = AsyncMock() + mock_synapse.get_client.return_value = mock_client + expected_permissions = {"canRead": True, "canUpdate": False} + mock_client.rest_get_async.return_value = expected_permissions + + # WHEN I call get_evaluation_permissions + result = await evaluation_services.get_evaluation_permissions( + evaluation_id=EVALUATION_ID, synapse_client=None + ) + + # THEN I expect a GET to /evaluation/{id}/permissions + assert result == expected_permissions + mock_client.rest_get_async.assert_awaited_once_with( + f"/evaluation/{EVALUATION_ID}/permissions" + ) + + +class TestCreateSubmission: + """Tests for create_submission function.""" + + @patch("synapseclient.Synapse") + async def test_create_submission(self, mock_synapse): + """Test creating a submission.""" + # GIVEN a mock client that returns a created submission + mock_client = AsyncMock() + mock_synapse.get_client.return_value = mock_client + request_body = { + "evaluationId": EVALUATION_ID, + "entityId": PROJECT_ID, + "versionNumber": 1, + } + expected_response = {"id": SUBMISSION_ID, **request_body} + mock_client.rest_post_async.return_value = expected_response + + # WHEN I call create_submission + result = await evaluation_services.create_submission( + request_body=request_body, etag=ETAG, synapse_client=None + ) + + # THEN I expect a POST to /evaluation/submission with etag as query param + assert result == expected_response + mock_client.rest_post_async.assert_awaited_once_with( + "/evaluation/submission", + body=json.dumps(request_body), + params={"etag": ETAG}, + ) + + +class TestGetSubmission: + """Tests for get_submission function.""" + + @patch("synapseclient.Synapse") + async def test_get_submission(self, mock_synapse): + """Test getting a submission by ID.""" + # GIVEN a mock client that returns a submission + mock_client = AsyncMock() + mock_synapse.get_client.return_value = mock_client + expected_response = {"id": SUBMISSION_ID, "evaluationId": EVALUATION_ID} + mock_client.rest_get_async.return_value = expected_response + + # WHEN I call get_submission + result = await evaluation_services.get_submission( + submission_id=SUBMISSION_ID, synapse_client=None + ) + + # THEN I expect a GET to /evaluation/submission/{id} + assert result == expected_response + mock_client.rest_get_async.assert_awaited_once_with( + f"/evaluation/submission/{SUBMISSION_ID}" + ) + + +class TestDeleteSubmission: + """Tests for delete_submission function.""" + + @patch("synapseclient.Synapse") + async def test_delete_submission(self, mock_synapse): + """Test deleting a submission.""" + # GIVEN a mock client + mock_client = AsyncMock() + mock_synapse.get_client.return_value = mock_client + + # WHEN I call delete_submission + await evaluation_services.delete_submission( + submission_id=SUBMISSION_ID, synapse_client=None + ) + + # THEN I expect a DELETE to /evaluation/submission/{id} + mock_client.rest_delete_async.assert_awaited_once_with( + f"/evaluation/submission/{SUBMISSION_ID}" + ) + + +class TestCancelSubmission: + """Tests for cancel_submission function.""" + + @patch("synapseclient.Synapse") + async def test_cancel_submission(self, mock_synapse): + """Test cancelling a submission.""" + # GIVEN a mock client that returns the cancelled submission + mock_client = AsyncMock() + mock_synapse.get_client.return_value = mock_client + expected_response = {"id": SUBMISSION_ID, "cancelRequested": True} + mock_client.rest_put_async.return_value = expected_response + + # WHEN I call cancel_submission + result = await evaluation_services.cancel_submission( + submission_id=SUBMISSION_ID, synapse_client=None + ) + + # THEN I expect a PUT to /evaluation/submission/{id}/cancellation + assert result == expected_response + mock_client.rest_put_async.assert_awaited_once_with( + f"/evaluation/submission/{SUBMISSION_ID}/cancellation" + ) + + +class TestGetSubmissionStatus: + """Tests for get_submission_status function.""" + + @patch("synapseclient.Synapse") + async def test_get_submission_status(self, mock_synapse): + """Test getting submission status.""" + # GIVEN a mock client that returns a submission status + mock_client = AsyncMock() + mock_synapse.get_client.return_value = mock_client + expected_response = { + "id": SUBMISSION_ID, + "status": "RECEIVED", + "etag": ETAG, + } + mock_client.rest_get_async.return_value = expected_response + + # WHEN I call get_submission_status + result = await evaluation_services.get_submission_status( + submission_id=SUBMISSION_ID, synapse_client=None + ) + + # THEN I expect a GET to /evaluation/submission/{id}/status + assert result == expected_response + mock_client.rest_get_async.assert_awaited_once_with( + f"/evaluation/submission/{SUBMISSION_ID}/status" + ) + + +class TestUpdateSubmissionStatus: + """Tests for update_submission_status function.""" + + @patch("synapseclient.Synapse") + async def test_update_submission_status(self, mock_synapse): + """Test updating submission status.""" + # GIVEN a mock client that returns the updated status + mock_client = AsyncMock() + mock_synapse.get_client.return_value = mock_client + request_body = { + "id": SUBMISSION_ID, + "status": "SCORED", + "etag": ETAG, + "score": 0.95, + } + expected_response = dict(request_body) + mock_client.rest_put_async.return_value = expected_response + + # WHEN I call update_submission_status + result = await evaluation_services.update_submission_status( + submission_id=SUBMISSION_ID, + request_body=request_body, + synapse_client=None, + ) + + # THEN I expect a PUT to /evaluation/submission/{id}/status + assert result == expected_response + mock_client.rest_put_async.assert_awaited_once_with( + f"/evaluation/submission/{SUBMISSION_ID}/status", + body=json.dumps(request_body), + ) + + +class TestBatchUpdateSubmissionStatuses: + """Tests for batch_update_submission_statuses function.""" + + @patch("synapseclient.Synapse") + async def test_batch_update_submission_statuses(self, mock_synapse): + """Test batch updating submission statuses.""" + # GIVEN a mock client that returns a batch response + mock_client = AsyncMock() + mock_synapse.get_client.return_value = mock_client + request_body = { + "statuses": [ + {"id": "111", "status": "SCORED", "etag": "etag1"}, + {"id": "222", "status": "SCORED", "etag": "etag2"}, + ], + "isFirstBatch": True, + "isLastBatch": True, + } + expected_response = {"nextUploadToken": BATCH_TOKEN} + mock_client.rest_put_async.return_value = expected_response + + # WHEN I call batch_update_submission_statuses + result = await evaluation_services.batch_update_submission_statuses( + evaluation_id=EVALUATION_ID, + request_body=request_body, + synapse_client=None, + ) + + # THEN I expect a PUT to /evaluation/{id}/statusBatch + assert result == expected_response + mock_client.rest_put_async.assert_awaited_once_with( + f"/evaluation/{EVALUATION_ID}/statusBatch", + body=json.dumps(request_body), + ) + + +class TestGetAllSubmissionStatuses: + """Tests for get_all_submission_statuses function.""" + + @patch("synapseclient.Synapse") + async def test_get_all_submission_statuses_defaults(self, mock_synapse): + """Test getting all submission statuses with default params.""" + # GIVEN a mock client that returns paginated statuses + mock_client = AsyncMock() + mock_synapse.get_client.return_value = mock_client + expected_response = { + "results": [{"id": SUBMISSION_ID, "status": "RECEIVED"}], + "totalNumberOfResults": 1, + } + mock_client.rest_get_async.return_value = expected_response + + # WHEN I call get_all_submission_statuses with defaults + result = await evaluation_services.get_all_submission_statuses( + evaluation_id=EVALUATION_ID, synapse_client=None + ) + + # THEN I expect a GET to /evaluation/{id}/submission/status/all + assert result == expected_response + mock_client.rest_get_async.assert_awaited_once_with( + f"/evaluation/{EVALUATION_ID}/submission/status/all", + params={"limit": 10, "offset": 0}, + ) + + @patch("synapseclient.Synapse") + async def test_get_all_submission_statuses_with_status_filter(self, mock_synapse): + """Test getting all submission statuses with status filter.""" + # GIVEN a mock client + mock_client = AsyncMock() + mock_synapse.get_client.return_value = mock_client + expected_response = {"results": [], "totalNumberOfResults": 0} + mock_client.rest_get_async.return_value = expected_response + + # WHEN I call with a status filter + result = await evaluation_services.get_all_submission_statuses( + evaluation_id=EVALUATION_ID, + status="SCORED", + limit=50, + offset=10, + synapse_client=None, + ) + + # THEN I expect status included in the params + assert result == expected_response + mock_client.rest_get_async.assert_awaited_once_with( + f"/evaluation/{EVALUATION_ID}/submission/status/all", + params={"limit": 50, "offset": 10, "status": "SCORED"}, + ) + + +class TestGetSubmissionCount: + """Tests for get_submission_count function.""" + + @patch("synapseclient.Synapse") + async def test_get_submission_count(self, mock_synapse): + """Test getting submission count for an evaluation.""" + # GIVEN a mock client that returns a count + mock_client = AsyncMock() + mock_synapse.get_client.return_value = mock_client + expected_response = {"count": 42} + mock_client.rest_get_async.return_value = expected_response + + # WHEN I call get_submission_count + result = await evaluation_services.get_submission_count( + evaluation_id=EVALUATION_ID, synapse_client=None + ) + + # THEN I expect a GET to /evaluation/{id}/submission/count + assert result == expected_response + mock_client.rest_get_async.assert_awaited_once_with( + f"/evaluation/{EVALUATION_ID}/submission/count" + ) diff --git a/tests/unit/synapseclient/api/unit_test_team_services.py b/tests/unit/synapseclient/api/unit_test_team_services.py new file mode 100644 index 000000000..47d23d0c7 --- /dev/null +++ b/tests/unit/synapseclient/api/unit_test_team_services.py @@ -0,0 +1,448 @@ +"""Unit tests for team_services utility functions.""" + +import json +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +import synapseclient.api.team_services as team_services + +TEAM_ID = 3412345 +TEAM_NAME = "My Test Team" +USER_ID = "1234567" +USER_NAME = "testuser" +INVITATION_ID = "inv-999" +INVITEE_EMAIL = "user@example.com" +MESSAGE = "Please join our team!" + + +class TestPostTeamList: + """Tests for post_team_list function.""" + + @patch("synapseclient.Synapse") + async def test_post_team_list_returns_teams(self, mock_synapse): + """Test retrieving a list of teams by IDs.""" + # GIVEN a mock client that returns a team list + mock_client = AsyncMock() + mock_synapse.get_client.return_value = mock_client + team_ids = [111, 222, 333] + expected_teams = [ + {"id": "111", "name": "Team A"}, + {"id": "222", "name": "Team B"}, + {"id": "333", "name": "Team C"}, + ] + mock_client.rest_post_async.return_value = {"list": expected_teams} + + # WHEN I call post_team_list + result = await team_services.post_team_list( + team_ids=team_ids, synapse_client=None + ) + + # THEN I expect the list of teams to be returned + assert result == expected_teams + mock_client.rest_post_async.assert_awaited_once_with( + uri="/teamList", body=json.dumps({"list": team_ids}) + ) + + @patch("synapseclient.Synapse") + async def test_post_team_list_empty_list_returns_none(self, mock_synapse): + """Test that an empty list response returns None.""" + # GIVEN a mock client that returns an empty list + mock_client = AsyncMock() + mock_synapse.get_client.return_value = mock_client + mock_client.rest_post_async.return_value = {"list": []} + + # WHEN I call post_team_list + result = await team_services.post_team_list(team_ids=[999], synapse_client=None) + + # THEN I expect None (empty list is falsy) + assert result is None + + @patch("synapseclient.Synapse") + async def test_post_team_list_no_list_key_returns_none(self, mock_synapse): + """Test that a response without 'list' key returns None.""" + # GIVEN a mock client that returns a response without 'list' + mock_client = AsyncMock() + mock_synapse.get_client.return_value = mock_client + mock_client.rest_post_async.return_value = {} + + # WHEN I call post_team_list + result = await team_services.post_team_list(team_ids=[999], synapse_client=None) + + # THEN I expect None + assert result is None + + +class TestCreateTeam: + """Tests for create_team function.""" + + @patch("synapseclient.Synapse") + async def test_create_team(self, mock_synapse): + """Test creating a new team.""" + # GIVEN a mock client that returns a created team + mock_client = AsyncMock() + mock_synapse.get_client.return_value = mock_client + expected_response = { + "id": str(TEAM_ID), + "name": TEAM_NAME, + "description": "A test team", + "canPublicJoin": False, + "canRequestMembership": True, + } + mock_client.rest_post_async.return_value = expected_response + + # WHEN I call create_team + result = await team_services.create_team( + name=TEAM_NAME, + description="A test team", + icon=None, + can_public_join=False, + can_request_membership=True, + synapse_client=None, + ) + + # THEN I expect a POST to /team with the correct body + assert result == expected_response + expected_body = { + "name": TEAM_NAME, + "description": "A test team", + "icon": None, + "canPublicJoin": False, + "canRequestMembership": True, + } + mock_client.rest_post_async.assert_awaited_once_with( + uri="/team", body=json.dumps(expected_body) + ) + + +class TestDeleteTeam: + """Tests for delete_team function.""" + + @patch("synapseclient.Synapse") + async def test_delete_team(self, mock_synapse): + """Test deleting a team by ID.""" + # GIVEN a mock client + mock_client = AsyncMock() + mock_synapse.get_client.return_value = mock_client + + # WHEN I call delete_team + await team_services.delete_team(id=TEAM_ID, synapse_client=None) + + # THEN I expect a DELETE to /team/{id} + mock_client.rest_delete_async.assert_awaited_once_with(uri=f"/team/{TEAM_ID}") + + +class TestGetTeam: + """Tests for get_team function.""" + + @patch("synapseclient.Synapse") + async def test_get_team_by_id(self, mock_synapse): + """Test getting a team by numeric ID.""" + # GIVEN a mock client that returns a team + mock_client = AsyncMock() + mock_synapse.get_client.return_value = mock_client + expected_response = {"id": str(TEAM_ID), "name": TEAM_NAME} + mock_client.rest_get_async.return_value = expected_response + + # WHEN I call get_team with a numeric ID + result = await team_services.get_team(id=TEAM_ID, synapse_client=None) + + # THEN I expect a GET to /team/{id} + assert result == expected_response + mock_client.rest_get_async.assert_awaited_once_with(uri=f"/team/{TEAM_ID}") + + @patch("synapseclient.api.team_services.find_team") + @patch("synapseclient.Synapse") + async def test_get_team_by_name(self, mock_synapse, mock_find_team): + """Test getting a team by name (string that is not a number).""" + # GIVEN a mock client and find_team that returns a matching team + mock_client = AsyncMock() + mock_synapse.get_client.return_value = mock_client + mock_find_team.return_value = [ + {"id": str(TEAM_ID), "name": TEAM_NAME}, + ] + expected_response = {"id": str(TEAM_ID), "name": TEAM_NAME} + mock_client.rest_get_async.return_value = expected_response + + # WHEN I call get_team with a string name + result = await team_services.get_team(id=TEAM_NAME, synapse_client=None) + + # THEN I expect find_team to be called and then a GET to /team/{id} + assert result == expected_response + mock_find_team.assert_awaited_once_with(TEAM_NAME, synapse_client=mock_client) + mock_client.rest_get_async.assert_awaited_once_with(uri=f"/team/{TEAM_ID}") + + @patch("synapseclient.api.team_services.find_team") + @patch("synapseclient.Synapse") + async def test_get_team_by_name_not_found(self, mock_synapse, mock_find_team): + """Test getting a team by name when the team does not exist.""" + # GIVEN a mock client and find_team that returns no match + mock_client = AsyncMock() + mock_synapse.get_client.return_value = mock_client + mock_find_team.return_value = [ + {"id": "999", "name": "Other Team"}, + ] + + # WHEN I call get_team with a name that doesn't match + # THEN I expect a ValueError + with pytest.raises(ValueError, match="Can't find team"): + await team_services.get_team(id=TEAM_NAME, synapse_client=None) + + +class TestFindTeam: + """Tests for find_team function.""" + + @patch("synapseclient.api.team_services.rest_get_paginated_async") + @patch("synapseclient.Synapse") + async def test_find_team(self, mock_synapse, mock_paginated): + """Test finding teams by name fragment.""" + # GIVEN a mock client and paginated results + mock_client = AsyncMock() + mock_synapse.get_client.return_value = mock_client + + async def mock_gen(*args, **kwargs): + for item in [ + {"id": "111", "name": "Team Alpha"}, + {"id": "222", "name": "Team Beta"}, + ]: + yield item + + mock_paginated.return_value = mock_gen() + + # WHEN I call find_team + result = await team_services.find_team(name="Team", synapse_client=None) + + # THEN I expect a list of matching teams + assert len(result) == 2 + assert result[0]["name"] == "Team Alpha" + mock_paginated.assert_called_once_with( + uri="/teams?fragment=Team", synapse_client=mock_client + ) + + +class TestGetTeamMembers: + """Tests for get_team_members function.""" + + @patch("synapseclient.api.team_services.rest_get_paginated_async") + @patch("synapseclient.Synapse") + async def test_get_team_members(self, mock_synapse, mock_paginated): + """Test getting team members.""" + # GIVEN a mock client and paginated results + mock_client = AsyncMock() + mock_synapse.get_client.return_value = mock_client + + async def mock_gen(*args, **kwargs): + for item in [ + {"member": {"ownerId": "111", "userName": "user1"}}, + {"member": {"ownerId": "222", "userName": "user2"}}, + ]: + yield item + + mock_paginated.return_value = mock_gen() + + # WHEN I call get_team_members + result = await team_services.get_team_members(team=TEAM_ID, synapse_client=None) + + # THEN I expect a list of team member dicts + assert len(result) == 2 + mock_paginated.assert_called_once_with( + uri=f"/teamMembers/{TEAM_ID}", synapse_client=mock_client + ) + + +class TestSendMembershipInvitation: + """Tests for send_membership_invitation function.""" + + @patch("synapseclient.Synapse") + async def test_send_membership_invitation_with_user_id(self, mock_synapse): + """Test sending invitation with invitee_id.""" + # GIVEN a mock client that returns an invitation + mock_client = AsyncMock() + mock_synapse.get_client.return_value = mock_client + expected_response = { + "id": INVITATION_ID, + "teamId": str(TEAM_ID), + "inviteeId": USER_ID, + } + mock_client.rest_post_async.return_value = expected_response + + # WHEN I call send_membership_invitation with user id + result = await team_services.send_membership_invitation( + team_id=TEAM_ID, + invitee_id=USER_ID, + message=MESSAGE, + synapse_client=None, + ) + + # THEN I expect a POST to /membershipInvitation + assert result == expected_response + expected_body = { + "teamId": str(TEAM_ID), + "message": MESSAGE, + "inviteeId": str(USER_ID), + } + mock_client.rest_post_async.assert_awaited_once_with( + uri="/membershipInvitation", body=json.dumps(expected_body) + ) + + @patch("synapseclient.Synapse") + async def test_send_membership_invitation_with_email(self, mock_synapse): + """Test sending invitation with invitee_email.""" + # GIVEN a mock client that returns an invitation + mock_client = AsyncMock() + mock_synapse.get_client.return_value = mock_client + expected_response = { + "id": INVITATION_ID, + "teamId": str(TEAM_ID), + "inviteeEmail": INVITEE_EMAIL, + } + mock_client.rest_post_async.return_value = expected_response + + # WHEN I call send_membership_invitation with email + result = await team_services.send_membership_invitation( + team_id=TEAM_ID, + invitee_email=INVITEE_EMAIL, + message=MESSAGE, + synapse_client=None, + ) + + # THEN I expect a POST to /membershipInvitation + assert result == expected_response + expected_body = { + "teamId": str(TEAM_ID), + "message": MESSAGE, + "inviteeEmail": str(INVITEE_EMAIL), + } + mock_client.rest_post_async.assert_awaited_once_with( + uri="/membershipInvitation", body=json.dumps(expected_body) + ) + + +class TestInviteToTeam: + """Tests for invite_to_team function.""" + + @patch("synapseclient.api.team_services.send_membership_invitation") + @patch("synapseclient.api.team_services.get_membership_status") + @patch("synapseclient.api.team_services.get_team_open_invitations") + @patch("synapseclient.models.UserProfile.get_async") + async def test_invite_to_team_already_member( + self, mock_get_profile, mock_open_invites, mock_membership_status, mock_send + ): + """Test invite_to_team when user is already a member returns None.""" + # GIVEN a user who is already a member + mock_profile = MagicMock() + mock_profile.id = int(USER_ID) + mock_profile.username = USER_NAME + mock_get_profile.return_value = mock_profile + mock_open_invites.return_value = [] + mock_membership_status.return_value = {"isMember": True} + + # WHEN I call invite_to_team + with patch("synapseclient.Synapse") as mock_syn_class: + mock_client = MagicMock() + mock_client.logger = MagicMock() + mock_syn_class.get_client.return_value = mock_client + result = await team_services.invite_to_team( + team=TEAM_ID, user=USER_NAME, synapse_client=None + ) + + # THEN I expect None and no invitation sent + assert result is None + mock_send.assert_not_awaited() + + @patch("synapseclient.api.team_services.send_membership_invitation") + @patch("synapseclient.api.team_services.delete_membership_invitation") + @patch("synapseclient.api.team_services.get_membership_status") + @patch("synapseclient.api.team_services.get_team_open_invitations") + @patch("synapseclient.models.UserProfile.get_async") + async def test_invite_to_team_force_delete_existing_invitation( + self, + mock_get_profile, + mock_open_invites, + mock_membership_status, + mock_delete_invite, + mock_send, + ): + """Test invite_to_team with force=True deletes existing invitations.""" + # GIVEN a user with an existing open invitation + mock_profile = MagicMock() + mock_profile.id = int(USER_ID) + mock_profile.username = USER_NAME + mock_get_profile.return_value = mock_profile + mock_open_invites.return_value = [ + {"id": INVITATION_ID, "inviteeId": int(USER_ID)}, + ] + mock_membership_status.return_value = {"isMember": False} + expected_invite = {"id": "new-inv", "teamId": str(TEAM_ID)} + mock_send.return_value = expected_invite + + # WHEN I call invite_to_team with force=True + result = await team_services.invite_to_team( + team=TEAM_ID, user=USER_NAME, force=True, synapse_client=None + ) + + # THEN I expect the old invitation to be deleted and a new one created + assert result == expected_invite + mock_delete_invite.assert_awaited_once_with( + invitation_id=INVITATION_ID, synapse_client=None + ) + mock_send.assert_awaited_once() + + async def test_invite_to_team_both_user_and_email_raises(self): + """Test that providing both user and inviteeEmail raises ValueError.""" + # GIVEN both user and invitee_email are specified + # WHEN I call invite_to_team + # THEN I expect a ValueError + with pytest.raises( + ValueError, match="Must specify either 'user' or 'inviteeEmail'" + ): + await team_services.invite_to_team( + team=TEAM_ID, + user=USER_NAME, + invitee_email=INVITEE_EMAIL, + synapse_client=None, + ) + + async def test_invite_to_team_neither_user_nor_email_raises(self): + """Test that providing neither user nor inviteeEmail raises ValueError.""" + # GIVEN neither user nor invitee_email is specified + # WHEN I call invite_to_team + # THEN I expect a ValueError + with pytest.raises( + ValueError, match="Must specify either 'user' or 'inviteeEmail'" + ): + await team_services.invite_to_team( + team=TEAM_ID, + synapse_client=None, + ) + + @patch("synapseclient.api.team_services.send_membership_invitation") + @patch("synapseclient.api.team_services.get_team_open_invitations") + async def test_invite_to_team_by_email(self, mock_open_invites, mock_send): + """Test invite_to_team with email only (no user).""" + # GIVEN no existing invitations for the email + mock_open_invites.return_value = [] + expected_invite = { + "id": "new-inv", + "teamId": str(TEAM_ID), + "inviteeEmail": INVITEE_EMAIL, + } + mock_send.return_value = expected_invite + + # WHEN I call invite_to_team with email + result = await team_services.invite_to_team( + team=TEAM_ID, + invitee_email=INVITEE_EMAIL, + message=MESSAGE, + synapse_client=None, + ) + + # THEN I expect the invitation to be created + assert result == expected_invite + mock_send.assert_awaited_once_with( + str(TEAM_ID), + invitee_id=None, + invitee_email=INVITEE_EMAIL, + message=MESSAGE, + synapse_client=None, + ) diff --git a/tests/unit/synapseclient/api/unit_test_user_services.py b/tests/unit/synapseclient/api/unit_test_user_services.py new file mode 100644 index 000000000..db97bf635 --- /dev/null +++ b/tests/unit/synapseclient/api/unit_test_user_services.py @@ -0,0 +1,407 @@ +"""Unit tests for user_services utility functions.""" + +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +import synapseclient.api.user_services as user_services +from synapseclient.core.exceptions import ( + SynapseError, + SynapseHTTPError, + SynapseNotFoundError, +) + +USER_ID = 1234567 +USER_NAME = "testuser" +OWNER_ID = "1234567" +PUBLIC_ID = 273949 +BUNDLE_MASK = 63 + + +class TestGetUserProfileById: + """Tests for get_user_profile_by_id function.""" + + @patch("synapseclient.Synapse") + async def test_get_user_profile_by_id(self, mock_synapse): + """Test getting a user profile by numeric ID.""" + # GIVEN a mock client that returns a user profile + mock_client = AsyncMock() + mock_synapse.get_client.return_value = mock_client + expected_response = { + "ownerId": OWNER_ID, + "userName": USER_NAME, + "firstName": "Test", + } + mock_client.rest_get_async.return_value = expected_response + + # WHEN I call get_user_profile_by_id with an id + result = await user_services.get_user_profile_by_id( + id=USER_ID, synapse_client=None + ) + + # THEN I expect a GET to /userProfile/{id} + assert result == expected_response + mock_client.rest_get_async.assert_awaited_once_with( + uri=f"/userProfile/{USER_ID}" + ) + + @patch("synapseclient.Synapse") + async def test_get_user_profile_by_id_no_id(self, mock_synapse): + """Test getting current user profile when id is omitted.""" + # GIVEN a mock client that returns the current user profile + mock_client = AsyncMock() + mock_synapse.get_client.return_value = mock_client + expected_response = {"ownerId": "999", "userName": "currentuser"} + mock_client.rest_get_async.return_value = expected_response + + # WHEN I call get_user_profile_by_id without an id + result = await user_services.get_user_profile_by_id(synapse_client=None) + + # THEN I expect a GET to /userProfile/ (empty id) + assert result == expected_response + mock_client.rest_get_async.assert_awaited_once_with(uri="/userProfile/") + + @patch("synapseclient.Synapse") + async def test_get_user_profile_by_id_non_int_raises_type_error(self, mock_synapse): + """Test that passing a non-int id raises TypeError.""" + # GIVEN a mock client + mock_client = AsyncMock() + mock_synapse.get_client.return_value = mock_client + + # WHEN I call get_user_profile_by_id with a string id + # THEN I expect a TypeError + with pytest.raises(TypeError, match="id must be an 'ownerId' integer"): + await user_services.get_user_profile_by_id( + id="not_an_int", synapse_client=None + ) + + +class TestGetUserProfileByUsername: + """Tests for get_user_profile_by_username function.""" + + @patch("synapseclient.api.user_services._find_principals") + @patch("synapseclient.Synapse") + async def test_get_user_profile_by_username( + self, mock_synapse, mock_find_principals + ): + """Test getting a user profile by username.""" + # GIVEN a mock client and matching principal + mock_client = AsyncMock() + mock_synapse.get_client.return_value = mock_client + mock_find_principals.return_value = [ + {"userName": USER_NAME, "ownerId": OWNER_ID}, + ] + expected_response = {"ownerId": OWNER_ID, "userName": USER_NAME} + mock_client.rest_get_async.return_value = expected_response + + # WHEN I call get_user_profile_by_username + result = await user_services.get_user_profile_by_username( + username=USER_NAME, synapse_client=None + ) + + # THEN I expect a GET to /userProfile/{id} after principal lookup + assert result == expected_response + mock_find_principals.assert_awaited_once_with(USER_NAME, synapse_client=None) + mock_client.rest_get_async.assert_awaited_once_with( + uri=f"/userProfile/{OWNER_ID}" + ) + + @patch("synapseclient.Synapse") + async def test_get_user_profile_by_username_none(self, mock_synapse): + """Test getting current user profile when username is None.""" + # GIVEN a mock client that returns current user + mock_client = AsyncMock() + mock_synapse.get_client.return_value = mock_client + expected_response = {"ownerId": "999", "userName": "currentuser"} + mock_client.rest_get_async.return_value = expected_response + + # WHEN I call get_user_profile_by_username with None + result = await user_services.get_user_profile_by_username( + username=None, synapse_client=None + ) + + # THEN I expect a GET to /userProfile/ (empty id) + assert result == expected_response + mock_client.rest_get_async.assert_awaited_once_with(uri="/userProfile/") + + @patch("synapseclient.api.user_services._find_principals") + @patch("synapseclient.Synapse") + async def test_get_user_profile_by_username_not_found( + self, mock_synapse, mock_find_principals + ): + """Test that SynapseNotFoundError is raised when username is not found.""" + # GIVEN a mock client and no matching principals + mock_client = AsyncMock() + mock_synapse.get_client.return_value = mock_client + mock_find_principals.return_value = [ + {"userName": "otheruser", "ownerId": "999"}, + ] + + # WHEN I call get_user_profile_by_username with a non-matching name + # THEN I expect SynapseNotFoundError + with pytest.raises(SynapseNotFoundError, match="Can't find user"): + await user_services.get_user_profile_by_username( + username="nonexistent", synapse_client=None + ) + + +class TestIsUserCertified: + """Tests for is_user_certified function.""" + + @patch("synapseclient.api.user_services._get_certified_passing_record") + async def test_is_user_certified_true(self, mock_get_record): + """Test that a certified user returns True.""" + # GIVEN a user who has passed the certification quiz + mock_get_record.return_value = {"passed": True, "quizId": 1} + + # WHEN I call is_user_certified with a numeric user id + result = await user_services.is_user_certified( + user=USER_ID, synapse_client=None + ) + + # THEN I expect True + assert result is True + mock_get_record.assert_awaited_once_with(USER_ID, synapse_client=None) + + @patch("synapseclient.api.user_services._get_certified_passing_record") + async def test_is_user_certified_false(self, mock_get_record): + """Test that a non-certified user returns False.""" + # GIVEN a user who has not passed the certification quiz + mock_get_record.return_value = {"passed": False, "quizId": 1} + + # WHEN I call is_user_certified + result = await user_services.is_user_certified( + user=USER_ID, synapse_client=None + ) + + # THEN I expect False + assert result is False + + @patch("synapseclient.api.user_services._get_certified_passing_record") + async def test_is_user_certified_not_found_returns_false(self, mock_get_record): + """Test that a 404 for passing record returns False.""" + # GIVEN a user who hasn't taken the quiz (404 response) + mock_response = MagicMock() + mock_response.status_code = 404 + mock_get_record.side_effect = SynapseHTTPError( + "Not found", response=mock_response + ) + + # WHEN I call is_user_certified + result = await user_services.is_user_certified( + user=USER_ID, synapse_client=None + ) + + # THEN I expect False + assert result is False + + @patch("synapseclient.api.user_services._find_principals") + async def test_is_user_certified_username_not_found_raises( + self, mock_find_principals + ): + """Test that ValueError is raised when username cannot be resolved.""" + # GIVEN no matching principals for the username + mock_find_principals.return_value = [ + {"userName": "someone_else", "ownerId": "999"}, + ] + + # WHEN I call is_user_certified with a non-matching username + # THEN I expect a ValueError + with pytest.raises(ValueError, match="Can't find user"): + await user_services.is_user_certified( + user="nonexistent_user", synapse_client=None + ) + + +class TestGetUserByPrincipalIdOrName: + """Tests for get_user_by_principal_id_or_name function.""" + + @patch("synapseclient.Synapse") + async def test_get_user_none_returns_public(self, mock_synapse): + """Test that None principal_id returns PUBLIC constant.""" + # GIVEN a mock client + mock_client = AsyncMock() + mock_synapse.get_client.return_value = mock_client + + # WHEN I call get_user_by_principal_id_or_name with None + result = await user_services.get_user_by_principal_id_or_name( + principal_id=None, synapse_client=None + ) + + # THEN I expect PUBLIC id (273949) + assert result == PUBLIC_ID + + @patch("synapseclient.Synapse") + async def test_get_user_public_string_returns_public(self, mock_synapse): + """Test that 'PUBLIC' string returns PUBLIC constant.""" + # GIVEN a mock client + mock_client = AsyncMock() + mock_synapse.get_client.return_value = mock_client + + # WHEN I call get_user_by_principal_id_or_name with "PUBLIC" + result = await user_services.get_user_by_principal_id_or_name( + principal_id="PUBLIC", synapse_client=None + ) + + # THEN I expect PUBLIC id (273949) + assert result == PUBLIC_ID + + @patch("synapseclient.Synapse") + async def test_get_user_by_int(self, mock_synapse): + """Test that an integer principal_id is returned directly.""" + # GIVEN a mock client + mock_client = AsyncMock() + mock_synapse.get_client.return_value = mock_client + + # WHEN I call get_user_by_principal_id_or_name with an int + result = await user_services.get_user_by_principal_id_or_name( + principal_id=USER_ID, synapse_client=None + ) + + # THEN I expect the int ID returned directly + assert result == USER_ID + + @patch("synapseclient.Synapse") + async def test_get_user_by_string_single_match(self, mock_synapse): + """Test looking up a user by name string with a single match.""" + # GIVEN a mock client that returns a single matching user + mock_client = AsyncMock() + mock_synapse.get_client.return_value = mock_client + mock_client.rest_get_async.return_value = { + "children": [{"ownerId": OWNER_ID, "userName": USER_NAME}] + } + + # WHEN I call get_user_by_principal_id_or_name with a string name + result = await user_services.get_user_by_principal_id_or_name( + principal_id=USER_NAME, synapse_client=None + ) + + # THEN I expect the user's ownerId as int + assert result == USER_ID + mock_client.rest_get_async.assert_awaited_once_with( + uri=f"/userGroupHeaders?prefix={USER_NAME}" + ) + + @patch("synapseclient.Synapse") + async def test_get_user_by_string_multiple_matches_exact(self, mock_synapse): + """Test looking up a user by name string with multiple matches but exact match exists.""" + # GIVEN a mock client that returns multiple matching users + mock_client = AsyncMock() + mock_synapse.get_client.return_value = mock_client + mock_client.rest_get_async.return_value = { + "children": [ + {"ownerId": "999", "userName": "testuser2"}, + {"ownerId": OWNER_ID, "userName": USER_NAME}, + ] + } + + # WHEN I call get_user_by_principal_id_or_name with the exact name + result = await user_services.get_user_by_principal_id_or_name( + principal_id=USER_NAME, synapse_client=None + ) + + # THEN I expect the exact match's ownerId + assert result == USER_ID + + @patch("synapseclient.Synapse") + async def test_get_user_by_string_no_match_raises(self, mock_synapse): + """Test that SynapseError is raised when no match is found.""" + # GIVEN a mock client that returns no matching users + mock_client = AsyncMock() + mock_synapse.get_client.return_value = mock_client + mock_client.rest_get_async.return_value = {"children": []} + + # WHEN I call get_user_by_principal_id_or_name with an unknown name + # THEN I expect a SynapseError + with pytest.raises(SynapseError, match="Unknown Synapse user.*No matches"): + await user_services.get_user_by_principal_id_or_name( + principal_id="unknown_user", synapse_client=None + ) + + @patch("synapseclient.Synapse") + async def test_get_user_by_string_ambiguous_raises(self, mock_synapse): + """Test that SynapseError is raised when multiple matches exist and none is exact.""" + # GIVEN a mock client that returns multiple users with no exact match + mock_client = AsyncMock() + mock_synapse.get_client.return_value = mock_client + mock_client.rest_get_async.return_value = { + "children": [ + {"ownerId": "111", "userName": "testuser1"}, + {"ownerId": "222", "userName": "testuser2"}, + ] + } + + # WHEN I call get_user_by_principal_id_or_name with an ambiguous prefix + # THEN I expect a SynapseError asking to be more specific + with pytest.raises( + SynapseError, match="Unknown Synapse user.*Please be more specific" + ): + await user_services.get_user_by_principal_id_or_name( + principal_id="testuser", synapse_client=None + ) + + +class TestGetUserBundle: + """Tests for get_user_bundle function.""" + + @patch("synapseclient.Synapse") + async def test_get_user_bundle_success(self, mock_synapse): + """Test getting a user bundle successfully.""" + # GIVEN a mock client that returns a user bundle + mock_client = AsyncMock() + mock_synapse.get_client.return_value = mock_client + expected_response = { + "userId": str(USER_ID), + "userProfile": {"ownerId": OWNER_ID, "userName": USER_NAME}, + } + mock_client.rest_get_async.return_value = expected_response + + # WHEN I call get_user_bundle + result = await user_services.get_user_bundle( + user_id=USER_ID, mask=BUNDLE_MASK, synapse_client=None + ) + + # THEN I expect the user bundle + assert result == expected_response + mock_client.rest_get_async.assert_awaited_once_with( + uri=f"/user/{USER_ID}/bundle?mask={BUNDLE_MASK}" + ) + + @patch("synapseclient.Synapse") + async def test_get_user_bundle_not_found_returns_none(self, mock_synapse): + """Test that a 404 error returns None.""" + # GIVEN a mock client that raises a 404 error + mock_client = AsyncMock() + mock_synapse.get_client.return_value = mock_client + mock_response = MagicMock() + mock_response.status_code = 404 + mock_client.rest_get_async.side_effect = SynapseHTTPError( + "Not found", response=mock_response + ) + + # WHEN I call get_user_bundle for a non-existent user + result = await user_services.get_user_bundle( + user_id=9999999, mask=BUNDLE_MASK, synapse_client=None + ) + + # THEN I expect None + assert result is None + + @patch("synapseclient.Synapse") + async def test_get_user_bundle_other_error_raises(self, mock_synapse): + """Test that non-404 HTTP errors are raised.""" + # GIVEN a mock client that raises a 500 error + mock_client = AsyncMock() + mock_synapse.get_client.return_value = mock_client + mock_response = MagicMock() + mock_response.status_code = 500 + mock_client.rest_get_async.side_effect = SynapseHTTPError( + "Server error", response=mock_response + ) + + # WHEN I call get_user_bundle + # THEN I expect the error to be raised + with pytest.raises(SynapseHTTPError): + await user_services.get_user_bundle( + user_id=USER_ID, mask=BUNDLE_MASK, synapse_client=None + ) diff --git a/tests/unit/synapseclient/api/unit_test_wiki_services.py b/tests/unit/synapseclient/api/unit_test_wiki_services.py new file mode 100644 index 000000000..73d7c3faf --- /dev/null +++ b/tests/unit/synapseclient/api/unit_test_wiki_services.py @@ -0,0 +1,537 @@ +"""Unit tests for wiki_services utility functions.""" + +import json +from unittest.mock import AsyncMock, patch + +import pytest + +import synapseclient.api.wiki_services as wiki_services + +OWNER_ID = "syn123456" +WIKI_ID = "987654" +WIKI_VERSION = 3 +FILE_NAME = "diagram.png" +WIKI_REQUEST = { + "title": "Test Wiki", + "markdown": "# Hello World", + "attachmentFileHandleIds": [], +} +WIKI_ORDER_HINT = { + "ownerId": OWNER_ID, + "idList": [WIKI_ID, "111111"], + "etag": "etag-abc", +} + + +class TestPostWikiPage: + """Tests for post_wiki_page function.""" + + @patch("synapseclient.Synapse") + async def test_post_wiki_page(self, mock_synapse): + """Test creating a new wiki page.""" + # GIVEN a mock client that returns the created wiki page + mock_client = AsyncMock() + mock_synapse.get_client.return_value = mock_client + expected_response = {"id": WIKI_ID, **WIKI_REQUEST} + mock_client.rest_post_async.return_value = expected_response + + # WHEN I call post_wiki_page + result = await wiki_services.post_wiki_page( + owner_id=OWNER_ID, request=WIKI_REQUEST, synapse_client=None + ) + + # THEN I expect a POST to /entity/{ownerId}/wiki2 + assert result == expected_response + mock_client.rest_post_async.assert_awaited_once_with( + uri=f"/entity/{OWNER_ID}/wiki2", + body=json.dumps(WIKI_REQUEST), + ) + + +class TestGetWikiPage: + """Tests for get_wiki_page function.""" + + @patch("synapseclient.Synapse") + async def test_get_wiki_page_with_wiki_id(self, mock_synapse): + """Test getting a specific wiki page by wiki_id.""" + # GIVEN a mock client that returns a wiki page + mock_client = AsyncMock() + mock_synapse.get_client.return_value = mock_client + expected_response = {"id": WIKI_ID, "title": "Test Wiki"} + mock_client.rest_get_async.return_value = expected_response + + # WHEN I call get_wiki_page with a wiki_id + result = await wiki_services.get_wiki_page( + owner_id=OWNER_ID, wiki_id=WIKI_ID, synapse_client=None + ) + + # THEN I expect a GET to /entity/{ownerId}/wiki2/{wikiId} + assert result == expected_response + mock_client.rest_get_async.assert_awaited_once_with( + uri=f"/entity/{OWNER_ID}/wiki2/{WIKI_ID}", + params={}, + ) + + @patch("synapseclient.Synapse") + async def test_get_wiki_page_root(self, mock_synapse): + """Test getting the root wiki page (no wiki_id).""" + # GIVEN a mock client that returns the root wiki page + mock_client = AsyncMock() + mock_synapse.get_client.return_value = mock_client + expected_response = {"id": WIKI_ID, "title": "Root Page"} + mock_client.rest_get_async.return_value = expected_response + + # WHEN I call get_wiki_page without a wiki_id + result = await wiki_services.get_wiki_page( + owner_id=OWNER_ID, synapse_client=None + ) + + # THEN I expect a GET to /entity/{ownerId}/wiki2 + assert result == expected_response + mock_client.rest_get_async.assert_awaited_once_with( + uri=f"/entity/{OWNER_ID}/wiki2", + params={}, + ) + + @patch("synapseclient.Synapse") + async def test_get_wiki_page_with_version(self, mock_synapse): + """Test getting a wiki page at a specific version.""" + # GIVEN a mock client that returns a versioned wiki page + mock_client = AsyncMock() + mock_synapse.get_client.return_value = mock_client + expected_response = {"id": WIKI_ID, "title": "Test Wiki"} + mock_client.rest_get_async.return_value = expected_response + + # WHEN I call get_wiki_page with wiki_id and wiki_version + result = await wiki_services.get_wiki_page( + owner_id=OWNER_ID, + wiki_id=WIKI_ID, + wiki_version=WIKI_VERSION, + synapse_client=None, + ) + + # THEN I expect a GET with wikiVersion as a query parameter + assert result == expected_response + mock_client.rest_get_async.assert_awaited_once_with( + uri=f"/entity/{OWNER_ID}/wiki2/{WIKI_ID}", + params={"wikiVersion": WIKI_VERSION}, + ) + + +class TestPutWikiPage: + """Tests for put_wiki_page function.""" + + @patch("synapseclient.Synapse") + async def test_put_wiki_page(self, mock_synapse): + """Test updating a wiki page.""" + # GIVEN a mock client that returns the updated wiki page + mock_client = AsyncMock() + mock_synapse.get_client.return_value = mock_client + expected_response = {"id": WIKI_ID, **WIKI_REQUEST} + mock_client.rest_put_async.return_value = expected_response + + # WHEN I call put_wiki_page + result = await wiki_services.put_wiki_page( + owner_id=OWNER_ID, + wiki_id=WIKI_ID, + request=WIKI_REQUEST, + synapse_client=None, + ) + + # THEN I expect a PUT to /entity/{ownerId}/wiki2/{wikiId} + assert result == expected_response + mock_client.rest_put_async.assert_awaited_once_with( + uri=f"/entity/{OWNER_ID}/wiki2/{WIKI_ID}", + body=json.dumps(WIKI_REQUEST), + ) + + +class TestPutWikiVersion: + """Tests for put_wiki_version function.""" + + @patch("synapseclient.Synapse") + async def test_put_wiki_version(self, mock_synapse): + """Test restoring a specific version of a wiki page.""" + # GIVEN a mock client that returns the restored wiki page + mock_client = AsyncMock() + mock_synapse.get_client.return_value = mock_client + wiki_version_str = str(WIKI_VERSION) + expected_response = {"id": WIKI_ID, **WIKI_REQUEST} + mock_client.rest_put_async.return_value = expected_response + + # WHEN I call put_wiki_version + result = await wiki_services.put_wiki_version( + owner_id=OWNER_ID, + wiki_id=WIKI_ID, + wiki_version=wiki_version_str, + request=WIKI_REQUEST, + synapse_client=None, + ) + + # THEN I expect a PUT to /entity/{ownerId}/wiki2/{wikiId}/{wikiVersion} + assert result == expected_response + mock_client.rest_put_async.assert_awaited_once_with( + uri=f"/entity/{OWNER_ID}/wiki2/{WIKI_ID}/{wiki_version_str}", + body=json.dumps(WIKI_REQUEST), + ) + + +class TestDeleteWikiPage: + """Tests for delete_wiki_page function.""" + + @patch("synapseclient.Synapse") + async def test_delete_wiki_page(self, mock_synapse): + """Test deleting a wiki page.""" + # GIVEN a mock client + mock_client = AsyncMock() + mock_synapse.get_client.return_value = mock_client + + # WHEN I call delete_wiki_page + await wiki_services.delete_wiki_page( + owner_id=OWNER_ID, wiki_id=WIKI_ID, synapse_client=None + ) + + # THEN I expect a DELETE to /entity/{ownerId}/wiki2/{wikiId} + mock_client.rest_delete_async.assert_awaited_once_with( + uri=f"/entity/{OWNER_ID}/wiki2/{WIKI_ID}", + ) + + +class TestGetWikiHeaderTree: + """Tests for get_wiki_header_tree function.""" + + @patch("synapseclient.api.wiki_services.rest_get_paginated_async") + @patch("synapseclient.Synapse") + async def test_get_wiki_header_tree(self, mock_synapse, mock_paginated): + """Test getting the wiki header tree.""" + # GIVEN a mock client and paginated results + mock_client = AsyncMock() + mock_synapse.get_client.return_value = mock_client + + async def mock_gen(*args, **kwargs): + for item in [ + {"id": WIKI_ID, "title": "Root", "parentId": None}, + {"id": "111", "title": "Child", "parentId": WIKI_ID}, + ]: + yield item + + mock_paginated.return_value = mock_gen() + + # WHEN I call get_wiki_header_tree + results = [] + async for item in wiki_services.get_wiki_header_tree( + owner_id=OWNER_ID, synapse_client=None + ): + results.append(item) + + # THEN I expect all header items from the generator + assert len(results) == 2 + assert results[0]["title"] == "Root" + assert results[1]["title"] == "Child" + mock_paginated.assert_called_once_with( + uri=f"/entity/{OWNER_ID}/wikiheadertree2", + limit=20, + offset=0, + synapse_client=mock_client, + ) + + +class TestGetWikiHistory: + """Tests for get_wiki_history function.""" + + @patch("synapseclient.api.wiki_services.rest_get_paginated_async") + @patch("synapseclient.Synapse") + async def test_get_wiki_history(self, mock_synapse, mock_paginated): + """Test getting wiki page history.""" + # GIVEN a mock client and paginated results + mock_client = AsyncMock() + mock_synapse.get_client.return_value = mock_client + + async def mock_gen(*args, **kwargs): + for item in [ + {"version": "1", "modifiedOn": "2024-01-01"}, + {"version": "2", "modifiedOn": "2024-01-15"}, + ]: + yield item + + mock_paginated.return_value = mock_gen() + + # WHEN I call get_wiki_history + results = [] + async for item in wiki_services.get_wiki_history( + owner_id=OWNER_ID, wiki_id=WIKI_ID, synapse_client=None + ): + results.append(item) + + # THEN I expect all history snapshots + assert len(results) == 2 + assert results[0]["version"] == "1" + mock_paginated.assert_called_once_with( + uri=f"/entity/{OWNER_ID}/wiki2/{WIKI_ID}/wikihistory", + limit=20, + offset=0, + synapse_client=mock_client, + ) + + +class TestGetAttachmentHandles: + """Tests for get_attachment_handles function.""" + + @patch("synapseclient.Synapse") + async def test_get_attachment_handles(self, mock_synapse): + """Test getting attachment handles without version.""" + # GIVEN a mock client that returns file handles + mock_client = AsyncMock() + mock_synapse.get_client.return_value = mock_client + expected_response = { + "list": [ + {"id": "fh-111", "fileName": FILE_NAME}, + ] + } + mock_client.rest_get_async.return_value = expected_response + + # WHEN I call get_attachment_handles + result = await wiki_services.get_attachment_handles( + owner_id=OWNER_ID, wiki_id=WIKI_ID, synapse_client=None + ) + + # THEN I expect a GET to /entity/{ownerId}/wiki2/{wikiId}/attachmenthandles + assert result == expected_response + mock_client.rest_get_async.assert_awaited_once_with( + uri=f"/entity/{OWNER_ID}/wiki2/{WIKI_ID}/attachmenthandles", + params={}, + ) + + @patch("synapseclient.Synapse") + async def test_get_attachment_handles_with_version(self, mock_synapse): + """Test getting attachment handles at a specific version.""" + # GIVEN a mock client + mock_client = AsyncMock() + mock_synapse.get_client.return_value = mock_client + expected_response = {"list": []} + mock_client.rest_get_async.return_value = expected_response + + # WHEN I call get_attachment_handles with wiki_version + result = await wiki_services.get_attachment_handles( + owner_id=OWNER_ID, + wiki_id=WIKI_ID, + wiki_version=WIKI_VERSION, + synapse_client=None, + ) + + # THEN I expect wikiVersion in the params + assert result == expected_response + mock_client.rest_get_async.assert_awaited_once_with( + uri=f"/entity/{OWNER_ID}/wiki2/{WIKI_ID}/attachmenthandles", + params={"wikiVersion": WIKI_VERSION}, + ) + + +class TestGetAttachmentUrl: + """Tests for get_attachment_url function.""" + + @patch("synapseclient.Synapse") + async def test_get_attachment_url(self, mock_synapse): + """Test getting attachment download URL.""" + # GIVEN a mock client that returns a URL + mock_client = AsyncMock() + mock_synapse.get_client.return_value = mock_client + expected_url = "https://example.com/download/diagram.png" + mock_client.rest_get_async.return_value = expected_url + + # WHEN I call get_attachment_url + result = await wiki_services.get_attachment_url( + owner_id=OWNER_ID, + wiki_id=WIKI_ID, + file_name=FILE_NAME, + synapse_client=None, + ) + + # THEN I expect a GET to /entity/{ownerId}/wiki2/{wikiId}/attachment + assert result == expected_url + mock_client.rest_get_async.assert_awaited_once_with( + uri=f"/entity/{OWNER_ID}/wiki2/{WIKI_ID}/attachment", + params={"redirect": False, "fileName": FILE_NAME}, + ) + + @patch("synapseclient.Synapse") + async def test_get_attachment_url_with_version(self, mock_synapse): + """Test getting attachment URL at a specific wiki version.""" + # GIVEN a mock client + mock_client = AsyncMock() + mock_synapse.get_client.return_value = mock_client + expected_url = "https://example.com/download/diagram.png?v=3" + mock_client.rest_get_async.return_value = expected_url + + # WHEN I call get_attachment_url with wiki_version + result = await wiki_services.get_attachment_url( + owner_id=OWNER_ID, + wiki_id=WIKI_ID, + file_name=FILE_NAME, + wiki_version=WIKI_VERSION, + synapse_client=None, + ) + + # THEN I expect wikiVersion in the params + assert result == expected_url + mock_client.rest_get_async.assert_awaited_once_with( + uri=f"/entity/{OWNER_ID}/wiki2/{WIKI_ID}/attachment", + params={ + "redirect": False, + "fileName": FILE_NAME, + "wikiVersion": WIKI_VERSION, + }, + ) + + +class TestGetAttachmentPreviewUrl: + """Tests for get_attachment_preview_url function.""" + + @patch("synapseclient.Synapse") + async def test_get_attachment_preview_url(self, mock_synapse): + """Test getting attachment preview URL.""" + # GIVEN a mock client that returns a preview URL + mock_client = AsyncMock() + mock_synapse.get_client.return_value = mock_client + expected_url = "https://example.com/preview/diagram.png" + mock_client.rest_get_async.return_value = expected_url + + # WHEN I call get_attachment_preview_url + result = await wiki_services.get_attachment_preview_url( + owner_id=OWNER_ID, + wiki_id=WIKI_ID, + file_name=FILE_NAME, + synapse_client=None, + ) + + # THEN I expect a GET to /entity/{ownerId}/wiki2/{wikiId}/attachmentpreview + assert result == expected_url + mock_client.rest_get_async.assert_awaited_once_with( + uri=f"/entity/{OWNER_ID}/wiki2/{WIKI_ID}/attachmentpreview", + params={"redirect": False, "fileName": FILE_NAME}, + ) + + @patch("synapseclient.Synapse") + async def test_get_attachment_preview_url_with_version(self, mock_synapse): + """Test getting attachment preview URL at a specific version.""" + # GIVEN a mock client + mock_client = AsyncMock() + mock_synapse.get_client.return_value = mock_client + expected_url = "https://example.com/preview/diagram.png?v=3" + mock_client.rest_get_async.return_value = expected_url + + # WHEN I call get_attachment_preview_url with wiki_version + result = await wiki_services.get_attachment_preview_url( + owner_id=OWNER_ID, + wiki_id=WIKI_ID, + file_name=FILE_NAME, + wiki_version=WIKI_VERSION, + synapse_client=None, + ) + + # THEN I expect wikiVersion in the params + assert result == expected_url + mock_client.rest_get_async.assert_awaited_once_with( + uri=f"/entity/{OWNER_ID}/wiki2/{WIKI_ID}/attachmentpreview", + params={ + "redirect": False, + "fileName": FILE_NAME, + "wikiVersion": WIKI_VERSION, + }, + ) + + +class TestGetMarkdownUrl: + """Tests for get_markdown_url function.""" + + @patch("synapseclient.Synapse") + async def test_get_markdown_url(self, mock_synapse): + """Test getting markdown download URL.""" + # GIVEN a mock client that returns a markdown URL + mock_client = AsyncMock() + mock_synapse.get_client.return_value = mock_client + expected_url = "https://example.com/markdown/wiki.md" + mock_client.rest_get_async.return_value = expected_url + + # WHEN I call get_markdown_url + result = await wiki_services.get_markdown_url( + owner_id=OWNER_ID, wiki_id=WIKI_ID, synapse_client=None + ) + + # THEN I expect a GET to /entity/{ownerId}/wiki2/{wikiId}/markdown + assert result == expected_url + mock_client.rest_get_async.assert_awaited_once_with( + uri=f"/entity/{OWNER_ID}/wiki2/{WIKI_ID}/markdown", + params={"redirect": False}, + ) + + @patch("synapseclient.Synapse") + async def test_get_markdown_url_with_version(self, mock_synapse): + """Test getting markdown URL at a specific version.""" + # GIVEN a mock client + mock_client = AsyncMock() + mock_synapse.get_client.return_value = mock_client + expected_url = "https://example.com/markdown/wiki.md?v=3" + mock_client.rest_get_async.return_value = expected_url + + # WHEN I call get_markdown_url with wiki_version + result = await wiki_services.get_markdown_url( + owner_id=OWNER_ID, + wiki_id=WIKI_ID, + wiki_version=WIKI_VERSION, + synapse_client=None, + ) + + # THEN I expect wikiVersion in the params + assert result == expected_url + mock_client.rest_get_async.assert_awaited_once_with( + uri=f"/entity/{OWNER_ID}/wiki2/{WIKI_ID}/markdown", + params={"redirect": False, "wikiVersion": WIKI_VERSION}, + ) + + +class TestGetWikiOrderHint: + """Tests for get_wiki_order_hint function.""" + + @patch("synapseclient.Synapse") + async def test_get_wiki_order_hint(self, mock_synapse): + """Test getting wiki order hint.""" + # GIVEN a mock client that returns an order hint + mock_client = AsyncMock() + mock_synapse.get_client.return_value = mock_client + mock_client.rest_get_async.return_value = WIKI_ORDER_HINT + + # WHEN I call get_wiki_order_hint + result = await wiki_services.get_wiki_order_hint( + owner_id=OWNER_ID, synapse_client=None + ) + + # THEN I expect a GET to /entity/{ownerId}/wiki2orderhint + assert result == WIKI_ORDER_HINT + mock_client.rest_get_async.assert_awaited_once_with( + uri=f"/entity/{OWNER_ID}/wiki2orderhint", + ) + + +class TestPutWikiOrderHint: + """Tests for put_wiki_order_hint function.""" + + @patch("synapseclient.Synapse") + async def test_put_wiki_order_hint(self, mock_synapse): + """Test updating wiki order hint.""" + # GIVEN a mock client that returns the updated order hint + mock_client = AsyncMock() + mock_synapse.get_client.return_value = mock_client + mock_client.rest_put_async.return_value = WIKI_ORDER_HINT + + # WHEN I call put_wiki_order_hint + result = await wiki_services.put_wiki_order_hint( + owner_id=OWNER_ID, request=WIKI_ORDER_HINT, synapse_client=None + ) + + # THEN I expect a PUT to /entity/{ownerId}/wiki2orderhint + assert result == WIKI_ORDER_HINT + mock_client.rest_put_async.assert_awaited_once_with( + uri=f"/entity/{OWNER_ID}/wiki2orderhint", + body=json.dumps(WIKI_ORDER_HINT), + ) diff --git a/tests/unit/synapseclient/core/unit_test_async_to_sync.py b/tests/unit/synapseclient/core/unit_test_async_to_sync.py new file mode 100644 index 000000000..9d2957089 --- /dev/null +++ b/tests/unit/synapseclient/core/unit_test_async_to_sync.py @@ -0,0 +1,226 @@ +"""Unit tests for the async_to_sync decorator and related utilities.""" + +import asyncio +import sys +from unittest.mock import MagicMock + +import pytest + +from synapseclient.core.async_utils import ( + ClassOrInstance, + async_to_sync, + skip_async_to_sync, + wrap_async_to_sync, +) + + +class TestAsyncToSyncDecorator: + """Tests for the @async_to_sync class decorator.""" + + def test_creates_sync_methods_from_async(self): + """Verify the decorator generates sync methods for each _async method.""" + + @async_to_sync + class MyClass: + async def store_async(self): + return "stored" + + async def get_async(self, item_id): + return f"got {item_id}" + + async def delete_async(self): + return "deleted" + + obj = MyClass() + assert hasattr(obj, "store") + assert hasattr(obj, "get") + assert hasattr(obj, "delete") + + def test_sync_method_returns_correct_value(self): + """Verify sync wrappers propagate return values.""" + + @async_to_sync + class MyClass: + async def compute_async(self, x, y): + return x + y + + obj = MyClass() + result = obj.compute(3, 4) + assert result == 7 + + def test_sync_method_propagates_exceptions(self): + """Verify sync wrappers propagate exceptions from the async method.""" + + @async_to_sync + class MyClass: + async def fail_async(self): + raise ValueError("test error") + + obj = MyClass() + with pytest.raises(ValueError, match="test error"): + obj.fail() + + def test_sync_method_passes_args_and_kwargs(self): + """Verify arguments are forwarded correctly.""" + + @async_to_sync + class MyClass: + async def method_async(self, a, b, *, key=None): + return (a, b, key) + + obj = MyClass() + result = obj.method(1, 2, key="value") + assert result == (1, 2, "value") + + def test_async_methods_still_work(self): + """Verify original async methods are not broken by the decorator.""" + + @async_to_sync + class MyClass: + async def store_async(self): + return "async_stored" + + obj = MyClass() + result = asyncio.run(obj.store_async()) + assert result == "async_stored" + + def test_skip_async_to_sync_excludes_method(self): + """Verify @skip_async_to_sync prevents sync wrapper generation.""" + + @async_to_sync + class MyClass: + @skip_async_to_sync + async def internal_async(self): + return "internal" + + async def public_async(self): + return "public" + + obj = MyClass() + assert not hasattr(obj, "internal") + assert hasattr(obj, "public") + + def test_class_or_instance_method(self): + """Verify sync wrappers can be called as both class and instance methods.""" + + @async_to_sync + class MyClass: + async def store_async(self): + return "stored" + + obj = MyClass() + # Instance method call should work + result = obj.store() + assert result == "stored" + + def test_decorator_preserves_class_attributes(self): + """Verify the decorator doesn't break non-async class attributes.""" + + @async_to_sync + class MyClass: + class_var = "hello" + + def sync_method(self): + return "sync" + + async def async_method_async(self): + return "async" + + obj = MyClass() + assert MyClass.class_var == "hello" + assert obj.sync_method() == "sync" + assert obj.async_method() == "async" + + def test_none_return_value(self): + """Verify None return values are handled.""" + + @async_to_sync + class MyClass: + async def void_async(self): + pass + + obj = MyClass() + result = obj.void() + assert result is None + + +class TestWrapAsyncToSync: + """Tests for the wrap_async_to_sync utility function.""" + + def test_wraps_coroutine_to_sync(self): + """Verify a coroutine can be run synchronously.""" + + async def my_coro(): + return 42 + + result = wrap_async_to_sync(my_coro()) + assert result == 42 + + def test_wraps_coroutine_with_exception(self): + """Verify exceptions from coroutines propagate.""" + + async def my_coro(): + raise RuntimeError("async error") + + with pytest.raises(RuntimeError, match="async error"): + wrap_async_to_sync(my_coro()) + + +class TestPython314Compatibility: + """Tests for Python 3.14+ behavior where sync wrappers raise RuntimeError + when an event loop is already active.""" + + @pytest.mark.skipif( + sys.version_info < (3, 14), + reason="Only applicable on Python 3.14+", + ) + def test_wrap_async_to_sync_raises_with_active_loop_on_314(self): + """On Python 3.14+, wrap_async_to_sync should raise RuntimeError + when called from within an active event loop.""" + + async def _inner(): + async def my_coro(): + return 42 + + # This should raise because we're inside an active event loop + wrap_async_to_sync(my_coro()) + + with pytest.raises(RuntimeError, match="Python 3.14\\+"): + asyncio.run(_inner()) + + @pytest.mark.skipif( + sys.version_info < (3, 14), + reason="Only applicable on Python 3.14+", + ) + def test_async_to_sync_decorator_raises_with_active_loop_on_314(self): + """On Python 3.14+, sync methods generated by @async_to_sync should + raise RuntimeError when called from within an active event loop.""" + + @async_to_sync + class MyClass: + async def store_async(self): + return "stored" + + async def _inner(): + obj = MyClass() + obj.store() + + with pytest.raises(RuntimeError, match="Python 3.14\\+"): + asyncio.run(_inner()) + + @pytest.mark.skipif( + sys.version_info >= (3, 14), + reason="Only applicable on Python < 3.14", + ) + def test_sync_methods_work_without_active_loop_pre_314(self): + """On Python < 3.14, sync wrappers should work normally + when no event loop is active.""" + + @async_to_sync + class MyClass: + async def store_async(self): + return "stored" + + obj = MyClass() + result = obj.store() + assert result == "stored" diff --git a/tests/unit/synapseclient/models/async/unit_test_curation_async.py b/tests/unit/synapseclient/models/async/unit_test_curation_async.py new file mode 100644 index 000000000..53649445b --- /dev/null +++ b/tests/unit/synapseclient/models/async/unit_test_curation_async.py @@ -0,0 +1,907 @@ +"""Unit tests for the CurationTask and Grid models.""" + +from unittest.mock import AsyncMock, patch + +import pytest + +from synapseclient import Synapse +from synapseclient.core.constants.concrete_types import ( + FILE_BASED_METADATA_TASK_PROPERTIES, + RECORD_BASED_METADATA_TASK_PROPERTIES, +) +from synapseclient.models.curation import ( + CreateGridRequest, + CurationTask, + FileBasedMetadataTaskProperties, + Grid, + GridRecordSetExportRequest, + RecordBasedMetadataTaskProperties, + _create_task_properties_from_dict, +) +from synapseclient.models.recordset import ValidationSummary + +TASK_ID = 42 +TASK_ID_2 = 99 +DATA_TYPE = "genomics_data" +PROJECT_ID = "syn9876543" +INSTRUCTIONS = "Upload your genomics files" +ETAG = "etag-abc-123" +CREATED_ON = "2024-01-01T00:00:00.000Z" +MODIFIED_ON = "2024-01-02T00:00:00.000Z" +CREATED_BY = "111111" +MODIFIED_BY = "222222" +ASSIGNEE_PRINCIPAL_ID = "333333" +UPLOAD_FOLDER_ID = "syn1234567" +FILE_VIEW_ID = "syn2345678" +RECORD_SET_ID = "syn3456789" +SESSION_ID = "session-abc-123" +SOURCE_ENTITY_ID = "syn5555555" +GRID_ETAG = "grid-etag-456" +STARTED_BY = "user-1" +STARTED_ON = "2024-03-01T00:00:00.000Z" + + +def _get_file_based_task_api_response(): + """Return a mock CurationTask API response with file-based properties.""" + return { + "taskId": TASK_ID, + "dataType": DATA_TYPE, + "projectId": PROJECT_ID, + "instructions": INSTRUCTIONS, + "etag": ETAG, + "createdOn": CREATED_ON, + "modifiedOn": MODIFIED_ON, + "createdBy": CREATED_BY, + "modifiedBy": MODIFIED_BY, + "assigneePrincipalId": ASSIGNEE_PRINCIPAL_ID, + "taskProperties": { + "concreteType": FILE_BASED_METADATA_TASK_PROPERTIES, + "uploadFolderId": UPLOAD_FOLDER_ID, + "fileViewId": FILE_VIEW_ID, + }, + } + + +def _get_record_based_task_api_response(): + """Return a mock CurationTask API response with record-based properties.""" + return { + "taskId": TASK_ID, + "dataType": DATA_TYPE, + "projectId": PROJECT_ID, + "instructions": INSTRUCTIONS, + "etag": ETAG, + "createdOn": CREATED_ON, + "modifiedOn": MODIFIED_ON, + "createdBy": CREATED_BY, + "modifiedBy": MODIFIED_BY, + "assigneePrincipalId": None, + "taskProperties": { + "concreteType": RECORD_BASED_METADATA_TASK_PROPERTIES, + "recordSetId": RECORD_SET_ID, + }, + } + + +def _get_grid_session_response(): + """Return a mock grid session API response.""" + return { + "sessionId": SESSION_ID, + "startedBy": STARTED_BY, + "startedOn": STARTED_ON, + "etag": GRID_ETAG, + "modifiedOn": MODIFIED_ON, + "lastReplicaIdClient": 10, + "lastReplicaIdService": -5, + "gridJsonSchema$Id": "my-schema-id", + "sourceEntityId": SOURCE_ENTITY_ID, + } + + +class TestFileBasedMetadataTaskProperties: + """Tests for the FileBasedMetadataTaskProperties dataclass.""" + + def test_fill_from_dict(self) -> None: + # GIVEN a response dict with file-based metadata task properties + response = { + "uploadFolderId": UPLOAD_FOLDER_ID, + "fileViewId": FILE_VIEW_ID, + } + + # WHEN I fill a FileBasedMetadataTaskProperties from the dict + props = FileBasedMetadataTaskProperties() + props.fill_from_dict(response) + + # THEN the properties should be populated correctly + assert props.upload_folder_id == UPLOAD_FOLDER_ID + assert props.file_view_id == FILE_VIEW_ID + + def test_to_synapse_request(self) -> None: + # GIVEN a FileBasedMetadataTaskProperties object + props = FileBasedMetadataTaskProperties( + upload_folder_id=UPLOAD_FOLDER_ID, file_view_id=FILE_VIEW_ID + ) + + # WHEN I convert it to a request dict + request = props.to_synapse_request() + + # THEN the request should contain the correct values + assert request["concreteType"] == FILE_BASED_METADATA_TASK_PROPERTIES + assert request["uploadFolderId"] == UPLOAD_FOLDER_ID + assert request["fileViewId"] == FILE_VIEW_ID + + def test_to_synapse_request_none_values(self) -> None: + # GIVEN a FileBasedMetadataTaskProperties with no values + props = FileBasedMetadataTaskProperties() + + # WHEN I convert it to a request dict + request = props.to_synapse_request() + + # THEN the request should only contain concreteType + assert request == {"concreteType": FILE_BASED_METADATA_TASK_PROPERTIES} + + +class TestRecordBasedMetadataTaskProperties: + """Tests for the RecordBasedMetadataTaskProperties dataclass.""" + + def test_fill_from_dict(self) -> None: + # GIVEN a response dict with record-based metadata task properties + response = {"recordSetId": RECORD_SET_ID} + + # WHEN I fill a RecordBasedMetadataTaskProperties from the dict + props = RecordBasedMetadataTaskProperties() + props.fill_from_dict(response) + + # THEN the record_set_id should be populated + assert props.record_set_id == RECORD_SET_ID + + def test_to_synapse_request(self) -> None: + # GIVEN a RecordBasedMetadataTaskProperties object + props = RecordBasedMetadataTaskProperties(record_set_id=RECORD_SET_ID) + + # WHEN I convert it to a request dict + request = props.to_synapse_request() + + # THEN the request should contain the correct values + assert request["concreteType"] == RECORD_BASED_METADATA_TASK_PROPERTIES + assert request["recordSetId"] == RECORD_SET_ID + + +class TestCreateTaskPropertiesFromDict: + """Tests for the _create_task_properties_from_dict factory function.""" + + def test_file_based_properties(self) -> None: + # GIVEN a dict with file-based concrete type + data = { + "concreteType": FILE_BASED_METADATA_TASK_PROPERTIES, + "uploadFolderId": UPLOAD_FOLDER_ID, + "fileViewId": FILE_VIEW_ID, + } + + # WHEN I create task properties from the dict + result = _create_task_properties_from_dict(data) + + # THEN it should be a FileBasedMetadataTaskProperties + assert isinstance(result, FileBasedMetadataTaskProperties) + assert result.upload_folder_id == UPLOAD_FOLDER_ID + assert result.file_view_id == FILE_VIEW_ID + + def test_record_based_properties(self) -> None: + # GIVEN a dict with record-based concrete type + data = { + "concreteType": RECORD_BASED_METADATA_TASK_PROPERTIES, + "recordSetId": RECORD_SET_ID, + } + + # WHEN I create task properties from the dict + result = _create_task_properties_from_dict(data) + + # THEN it should be a RecordBasedMetadataTaskProperties + assert isinstance(result, RecordBasedMetadataTaskProperties) + assert result.record_set_id == RECORD_SET_ID + + def test_unknown_concrete_type_raises_error(self) -> None: + # GIVEN a dict with an unknown concrete type + data = {"concreteType": "org.sagebionetworks.Unknown"} + + # WHEN I attempt to create task properties + # THEN it should raise a ValueError + with pytest.raises(ValueError, match="Unknown concreteType"): + _create_task_properties_from_dict(data) + + +class TestCurationTask: + """Unit tests for the CurationTask model.""" + + @pytest.fixture(autouse=True, scope="function") + def init_syn(self, syn: Synapse) -> None: + self.syn = syn + + def test_fill_from_dict_file_based(self) -> None: + # GIVEN a CurationTask API response with file-based properties + response = _get_file_based_task_api_response() + + # WHEN I fill a CurationTask from the response + task = CurationTask() + task.fill_from_dict(response) + + # THEN all fields should be populated correctly + assert task.task_id == TASK_ID + assert task.data_type == DATA_TYPE + assert task.project_id == PROJECT_ID + assert task.instructions == INSTRUCTIONS + assert task.etag == ETAG + assert task.created_on == CREATED_ON + assert task.modified_on == MODIFIED_ON + assert task.created_by == CREATED_BY + assert task.modified_by == MODIFIED_BY + assert task.assignee_principal_id == ASSIGNEE_PRINCIPAL_ID + assert isinstance(task.task_properties, FileBasedMetadataTaskProperties) + assert task.task_properties.upload_folder_id == UPLOAD_FOLDER_ID + assert task.task_properties.file_view_id == FILE_VIEW_ID + + def test_fill_from_dict_record_based(self) -> None: + # GIVEN a CurationTask API response with record-based properties + response = _get_record_based_task_api_response() + + # WHEN I fill a CurationTask from the response + task = CurationTask() + task.fill_from_dict(response) + + # THEN the task_properties should be RecordBasedMetadataTaskProperties + assert isinstance(task.task_properties, RecordBasedMetadataTaskProperties) + assert task.task_properties.record_set_id == RECORD_SET_ID + + def test_to_synapse_request(self) -> None: + # GIVEN a CurationTask with all fields set + task = CurationTask( + task_id=TASK_ID, + data_type=DATA_TYPE, + project_id=PROJECT_ID, + instructions=INSTRUCTIONS, + etag=ETAG, + task_properties=FileBasedMetadataTaskProperties( + upload_folder_id=UPLOAD_FOLDER_ID, file_view_id=FILE_VIEW_ID + ), + ) + + # WHEN I convert it to a Synapse request + request = task.to_synapse_request() + + # THEN the request should contain the correct values + assert request["taskId"] == TASK_ID + assert request["dataType"] == DATA_TYPE + assert request["projectId"] == PROJECT_ID + assert request["instructions"] == INSTRUCTIONS + assert request["etag"] == ETAG + assert ( + request["taskProperties"]["concreteType"] + == FILE_BASED_METADATA_TASK_PROPERTIES + ) + assert request["taskProperties"]["uploadFolderId"] == UPLOAD_FOLDER_ID + + def test_has_changed_true_initially(self) -> None: + # GIVEN a new CurationTask + task = CurationTask(task_id=TASK_ID, data_type=DATA_TYPE) + + # WHEN I check has_changed before any persistent instance + # THEN it should be True + assert task.has_changed is True + + def test_has_changed_false_after_set(self) -> None: + # GIVEN a CurationTask with a persistent instance set + task = CurationTask(task_id=TASK_ID, data_type=DATA_TYPE) + task._set_last_persistent_instance() + + # WHEN I check has_changed without modifying + # THEN it should be False + assert task.has_changed is False + + def test_has_changed_true_after_modification(self) -> None: + # GIVEN a CurationTask with a persistent instance set + task = CurationTask(task_id=TASK_ID, data_type=DATA_TYPE) + task._set_last_persistent_instance() + + # WHEN I modify the task + task.instructions = "new instructions" + + # THEN has_changed should be True + assert task.has_changed is True + + async def test_get_async(self) -> None: + # GIVEN a CurationTask with a task_id + task = CurationTask(task_id=TASK_ID) + + # WHEN I call get_async + with patch( + "synapseclient.models.curation.get_curation_task", + new_callable=AsyncMock, + return_value=_get_file_based_task_api_response(), + ) as mock_get: + result = await task.get_async(synapse_client=self.syn) + + # THEN the API should be called with the task_id + mock_get.assert_called_once_with(task_id=TASK_ID, synapse_client=self.syn) + + # AND the result should be populated + assert result.task_id == TASK_ID + assert result.data_type == DATA_TYPE + assert result.project_id == PROJECT_ID + assert result.instructions == INSTRUCTIONS + assert isinstance(result.task_properties, FileBasedMetadataTaskProperties) + + async def test_get_async_without_task_id(self) -> None: + # GIVEN a CurationTask without a task_id + task = CurationTask() + + # WHEN I call get_async + # THEN it should raise ValueError + with pytest.raises(ValueError, match="task_id is required to get"): + await task.get_async(synapse_client=self.syn) + + async def test_delete_async(self) -> None: + # GIVEN a CurationTask with a task_id + task = CurationTask(task_id=TASK_ID) + + # WHEN I call delete_async + with patch( + "synapseclient.models.curation.delete_curation_task", + new_callable=AsyncMock, + return_value=None, + ) as mock_delete: + await task.delete_async(synapse_client=self.syn) + + # THEN the API should be called with the task_id + mock_delete.assert_called_once_with( + task_id=TASK_ID, synapse_client=self.syn + ) + + async def test_delete_async_without_task_id(self) -> None: + # GIVEN a CurationTask without a task_id + task = CurationTask() + + # WHEN I call delete_async + # THEN it should raise ValueError + with pytest.raises(ValueError, match="task_id is required to delete"): + await task.delete_async(synapse_client=self.syn) + + async def test_store_async_create_new_task(self) -> None: + # GIVEN a new CurationTask with all required create fields + file_props = FileBasedMetadataTaskProperties( + upload_folder_id=UPLOAD_FOLDER_ID, file_view_id=FILE_VIEW_ID + ) + task = CurationTask( + project_id=PROJECT_ID, + data_type=DATA_TYPE, + instructions=INSTRUCTIONS, + task_properties=file_props, + ) + + # WHEN I call store_async and no existing task is found + async def empty_list_gen(*args, **kwargs): + return + yield # pragma: no cover + + with patch( + "synapseclient.models.curation.list_curation_tasks", + return_value=empty_list_gen(), + ), patch( + "synapseclient.models.curation.create_curation_task", + new_callable=AsyncMock, + return_value=_get_file_based_task_api_response(), + ) as mock_create: + result = await task.store_async(synapse_client=self.syn) + + # THEN the create API should be called + mock_create.assert_called_once() + + # AND the result should be populated with the response + assert result.task_id == TASK_ID + assert result.data_type == DATA_TYPE + assert result.project_id == PROJECT_ID + + async def test_store_async_update_with_task_id(self) -> None: + # GIVEN a CurationTask with a task_id (already persisted) + task = CurationTask( + task_id=TASK_ID, + project_id=PROJECT_ID, + data_type=DATA_TYPE, + instructions="Updated instructions", + etag=ETAG, + ) + + # Capture what to_synapse_request returns before the call + expected_request = task.to_synapse_request() + + # WHEN I call store_async + with patch( + "synapseclient.models.curation.update_curation_task", + new_callable=AsyncMock, + return_value=_get_file_based_task_api_response(), + ) as mock_update: + result = await task.store_async(synapse_client=self.syn) + + # THEN the update API should be called with the task_id + mock_update.assert_called_once_with( + task_id=TASK_ID, + curation_task=expected_request, + synapse_client=self.syn, + ) + + # AND the result should be populated from the response + assert result.task_id == TASK_ID + assert result.data_type == DATA_TYPE + + async def test_store_async_merge_existing(self) -> None: + # GIVEN a CurationTask that matches an existing task by project_id and data_type + task = CurationTask( + project_id=PROJECT_ID, + data_type=DATA_TYPE, + instructions="New instructions only", + ) + + existing_response = _get_file_based_task_api_response() + + # Mock list_curation_tasks to return the existing task + async def mock_list(*args, **kwargs): + yield existing_response + + # WHEN I call store_async + with patch( + "synapseclient.models.curation.list_curation_tasks", + return_value=mock_list(), + ), patch( + "synapseclient.models.curation.get_curation_task", + new_callable=AsyncMock, + return_value=existing_response, + ), patch( + "synapseclient.models.curation.update_curation_task", + new_callable=AsyncMock, + return_value=existing_response, + ) as mock_update: + result = await task.store_async(synapse_client=self.syn) + + # THEN it should have merged the existing task and done an update + mock_update.assert_called_once() + + # AND the result should reflect the merged state + assert result.task_id == TASK_ID + + async def test_store_async_no_project_id_raises(self) -> None: + # GIVEN a CurationTask without a project_id + task = CurationTask(data_type=DATA_TYPE) + + # WHEN I call store_async + # THEN it should raise ValueError + with pytest.raises(ValueError, match="project_id is required"): + await task.store_async(synapse_client=self.syn) + + async def test_store_async_no_data_type_raises(self) -> None: + # GIVEN a CurationTask without a data_type + task = CurationTask(project_id=PROJECT_ID) + + # WHEN I call store_async + # THEN it should raise ValueError + with pytest.raises(ValueError, match="data_type is required"): + await task.store_async(synapse_client=self.syn) + + async def test_store_async_create_missing_instructions_raises(self) -> None: + # GIVEN a CurationTask without instructions (and no existing match) + task = CurationTask( + project_id=PROJECT_ID, + data_type=DATA_TYPE, + task_properties=FileBasedMetadataTaskProperties( + upload_folder_id=UPLOAD_FOLDER_ID + ), + ) + + async def empty_list_gen(*args, **kwargs): + return + yield # pragma: no cover + + # WHEN I call store_async + # THEN it should raise ValueError for missing instructions + with patch( + "synapseclient.models.curation.list_curation_tasks", + return_value=empty_list_gen(), + ): + with pytest.raises(ValueError, match="instructions is required"): + await task.store_async(synapse_client=self.syn) + + async def test_store_async_create_missing_task_properties_raises(self) -> None: + # GIVEN a CurationTask without task_properties (and no existing match) + task = CurationTask( + project_id=PROJECT_ID, + data_type=DATA_TYPE, + instructions=INSTRUCTIONS, + ) + + async def empty_list_gen(*args, **kwargs): + return + yield # pragma: no cover + + # WHEN I call store_async + # THEN it should raise ValueError for missing task_properties + with patch( + "synapseclient.models.curation.list_curation_tasks", + return_value=empty_list_gen(), + ): + with pytest.raises(ValueError, match="task_properties is required"): + await task.store_async(synapse_client=self.syn) + + async def test_list_async(self) -> None: + # GIVEN mock API responses for two tasks + task_response_1 = _get_file_based_task_api_response() + task_response_2 = _get_record_based_task_api_response() + task_response_2["taskId"] = TASK_ID_2 + + async def mock_list(*args, **kwargs): + yield task_response_1 + yield task_response_2 + + # WHEN I call list_async + with patch( + "synapseclient.models.curation.list_curation_tasks", + return_value=mock_list(), + ): + results = [] + async for task in CurationTask.list_async( + project_id=PROJECT_ID, synapse_client=self.syn + ): + results.append(task) + + # THEN I should get two CurationTask objects + assert len(results) == 2 + assert results[0].task_id == TASK_ID + assert results[0].data_type == DATA_TYPE + assert isinstance( + results[0].task_properties, FileBasedMetadataTaskProperties + ) + assert results[1].task_id == TASK_ID_2 + assert isinstance( + results[1].task_properties, RecordBasedMetadataTaskProperties + ) + + +class TestGrid: + """Unit tests for the Grid model.""" + + @pytest.fixture(autouse=True, scope="function") + def init_syn(self, syn: Synapse) -> None: + self.syn = syn + + def test_fill_from_dict(self) -> None: + # GIVEN a grid session API response + response = _get_grid_session_response() + + # WHEN I fill a Grid from the response + grid = Grid() + grid.fill_from_dict(response) + + # THEN all fields should be populated correctly + assert grid.session_id == SESSION_ID + assert grid.started_by == STARTED_BY + assert grid.started_on == STARTED_ON + assert grid.etag == GRID_ETAG + assert grid.modified_on == MODIFIED_ON + assert grid.last_replica_id_client == 10 + assert grid.last_replica_id_service == -5 + assert grid.grid_json_schema_id == "my-schema-id" + assert grid.source_entity_id == SOURCE_ENTITY_ID + + async def test_create_async_with_record_set_id(self) -> None: + # GIVEN a Grid with a record_set_id + grid = Grid(record_set_id=RECORD_SET_ID) + + # Mock the CreateGridRequest's send_job_and_wait_async + mock_create_request = CreateGridRequest(record_set_id=RECORD_SET_ID) + mock_create_request.session_id = SESSION_ID + mock_create_request._grid_session_data = _get_grid_session_response() + + # WHEN I call create_async + with patch.object( + CreateGridRequest, + "send_job_and_wait_async", + new_callable=AsyncMock, + return_value=mock_create_request, + ): + result = await grid.create_async(synapse_client=self.syn) + + # THEN the grid should be populated with session data + assert result.session_id == SESSION_ID + assert result.started_by == STARTED_BY + assert result.started_on == STARTED_ON + assert result.source_entity_id == SOURCE_ENTITY_ID + + async def test_create_async_no_record_set_or_query_raises(self) -> None: + # GIVEN a Grid with neither record_set_id nor initial_query + grid = Grid() + + # WHEN I call create_async + # THEN it should raise ValueError + with pytest.raises( + ValueError, match="record_set_id or initial_query is required" + ): + await grid.create_async(synapse_client=self.syn) + + async def test_create_async_attach_to_previous_session(self) -> None: + # GIVEN a Grid with a record_set_id + grid = Grid(record_set_id=RECORD_SET_ID) + + # Mock list_async to return an existing session + existing_grid = Grid() + existing_grid.fill_from_dict(_get_grid_session_response()) + + async def mock_list_async(*args, **kwargs): + yield existing_grid + + # WHEN I call create_async with attach_to_previous_session=True + with patch.object( + Grid, + "list_async", + return_value=mock_list_async(), + ): + result = await grid.create_async( + attach_to_previous_session=True, synapse_client=self.syn + ) + + # THEN the grid should attach to the existing session + assert result.session_id == SESSION_ID + assert result.started_by == STARTED_BY + assert result.source_entity_id == SOURCE_ENTITY_ID + + async def test_create_async_attach_to_previous_no_existing(self) -> None: + # GIVEN a Grid with a record_set_id + grid = Grid(record_set_id=RECORD_SET_ID) + + # Mock list_async to return no existing sessions + async def mock_list_async(*args, **kwargs): + return + yield # pragma: no cover + + mock_create_request = CreateGridRequest(record_set_id=RECORD_SET_ID) + mock_create_request.session_id = SESSION_ID + mock_create_request._grid_session_data = _get_grid_session_response() + + # WHEN I call create_async with attach_to_previous_session=True and no + # existing sessions + with patch.object( + Grid, + "list_async", + return_value=mock_list_async(), + ), patch.object( + CreateGridRequest, + "send_job_and_wait_async", + new_callable=AsyncMock, + return_value=mock_create_request, + ): + result = await grid.create_async( + attach_to_previous_session=True, synapse_client=self.syn + ) + + # THEN a new grid session should be created + assert result.session_id == SESSION_ID + + async def test_export_to_record_set_async(self) -> None: + # GIVEN a Grid with a session_id + grid = Grid(session_id=SESSION_ID) + + mock_export_result = GridRecordSetExportRequest(session_id=SESSION_ID) + mock_export_result.response_record_set_id = RECORD_SET_ID + mock_export_result.record_set_version_number = 3 + mock_export_result.validation_summary_statistics = ValidationSummary( + container_id="syn111", + total_number_of_children=10, + number_of_valid_children=8, + number_of_invalid_children=1, + number_of_unknown_children=1, + ) + + # WHEN I call export_to_record_set_async + with patch.object( + GridRecordSetExportRequest, + "send_job_and_wait_async", + new_callable=AsyncMock, + return_value=mock_export_result, + ): + result = await grid.export_to_record_set_async(synapse_client=self.syn) + + # THEN the export result should be populated + assert result.record_set_id == RECORD_SET_ID + assert result.record_set_version_number == 3 + assert result.validation_summary_statistics.number_of_valid_children == 8 + + async def test_export_to_record_set_async_without_session_id_raises(self) -> None: + # GIVEN a Grid without a session_id + grid = Grid() + + # WHEN I call export_to_record_set_async + # THEN it should raise ValueError + with pytest.raises(ValueError, match="session_id is required to export"): + await grid.export_to_record_set_async(synapse_client=self.syn) + + async def test_delete_async(self) -> None: + # GIVEN a Grid with a session_id + grid = Grid(session_id=SESSION_ID) + + # WHEN I call delete_async + with patch( + "synapseclient.models.curation.delete_grid_session", + new_callable=AsyncMock, + return_value=None, + ) as mock_delete: + await grid.delete_async(synapse_client=self.syn) + + # THEN the API should be called with the session_id + mock_delete.assert_called_once_with( + session_id=SESSION_ID, synapse_client=self.syn + ) + + async def test_delete_async_without_session_id_raises(self) -> None: + # GIVEN a Grid without a session_id + grid = Grid() + + # WHEN I call delete_async + # THEN it should raise ValueError + with pytest.raises(ValueError, match="session_id is required to delete"): + await grid.delete_async(synapse_client=self.syn) + + async def test_list_async(self) -> None: + # GIVEN mock API responses for grid sessions + session_1 = _get_grid_session_response() + session_2 = { + "sessionId": "session-xyz-999", + "startedBy": "user-2", + "startedOn": "2024-04-01T00:00:00.000Z", + "etag": "etag-2", + "modifiedOn": "2024-04-02T00:00:00.000Z", + "lastReplicaIdClient": 20, + "lastReplicaIdService": -10, + "gridJsonSchema$Id": None, + "sourceEntityId": "syn6666666", + } + + async def mock_list(*args, **kwargs): + yield session_1 + yield session_2 + + # WHEN I call list_async + with patch( + "synapseclient.models.curation.list_grid_sessions", + return_value=mock_list(), + ): + results = [] + async for grid in Grid.list_async(synapse_client=self.syn): + results.append(grid) + + # THEN I should get two Grid objects + assert len(results) == 2 + assert results[0].session_id == SESSION_ID + assert results[0].source_entity_id == SOURCE_ENTITY_ID + assert results[1].session_id == "session-xyz-999" + assert results[1].source_entity_id == "syn6666666" + + async def test_list_async_with_source_id(self) -> None: + # GIVEN mock API responses filtered by source_id + session_1 = _get_grid_session_response() + + async def mock_list(*args, **kwargs): + yield session_1 + + # WHEN I call list_async with a source_id + with patch( + "synapseclient.models.curation.list_grid_sessions", + return_value=mock_list(), + ): + results = [] + async for grid in Grid.list_async( + source_id=RECORD_SET_ID, synapse_client=self.syn + ): + results.append(grid) + + # THEN I should get the matching grid session + assert len(results) == 1 + assert results[0].session_id == SESSION_ID + + +class TestCreateGridRequest: + """Tests for the CreateGridRequest helper dataclass.""" + + def test_fill_from_dict(self) -> None: + # GIVEN a response with grid session data + response = {"gridSession": _get_grid_session_response()} + + # WHEN I fill a CreateGridRequest from the response + request = CreateGridRequest(record_set_id=RECORD_SET_ID) + request.fill_from_dict(response) + + # THEN the session_id should be populated + assert request.session_id == SESSION_ID + + def test_fill_grid_session_from_response(self) -> None: + # GIVEN a CreateGridRequest with stored grid session data + response = {"gridSession": _get_grid_session_response()} + request = CreateGridRequest(record_set_id=RECORD_SET_ID) + request.fill_from_dict(response) + + # WHEN I fill a Grid from the stored data + grid = Grid() + request.fill_grid_session_from_response(grid) + + # THEN the Grid should be populated + assert grid.session_id == SESSION_ID + assert grid.started_by == STARTED_BY + assert grid.etag == GRID_ETAG + assert grid.source_entity_id == SOURCE_ENTITY_ID + + def test_to_synapse_request_with_record_set_id(self) -> None: + # GIVEN a CreateGridRequest with a record_set_id + request = CreateGridRequest(record_set_id=RECORD_SET_ID) + + # WHEN I convert it to a synapse request + result = request.to_synapse_request() + + # THEN it should contain the correct fields + assert "concreteType" in result + assert result["recordSetId"] == RECORD_SET_ID + assert "initialQuery" not in result + + +class TestGridRecordSetExportRequest: + """Tests for the GridRecordSetExportRequest helper dataclass.""" + + def test_fill_from_dict(self) -> None: + # GIVEN a response with export data + response = { + "sessionId": SESSION_ID, + "recordSetId": RECORD_SET_ID, + "recordSetVersionNumber": 5, + "validationSummaryStatistics": { + "containerId": "syn111", + "totalNumberOfChildren": 10, + "numberOfValidChildren": 7, + "numberOfInvalidChildren": 2, + "numberOfUnknownChildren": 1, + "generatedOn": "2024-05-01T00:00:00.000Z", + }, + } + + # WHEN I fill a GridRecordSetExportRequest from the response + export_req = GridRecordSetExportRequest(session_id=SESSION_ID) + export_req.fill_from_dict(response) + + # THEN all fields should be populated + assert export_req.response_session_id == SESSION_ID + assert export_req.response_record_set_id == RECORD_SET_ID + assert export_req.record_set_version_number == 5 + assert export_req.validation_summary_statistics.container_id == "syn111" + assert export_req.validation_summary_statistics.total_number_of_children == 10 + assert export_req.validation_summary_statistics.number_of_valid_children == 7 + assert export_req.validation_summary_statistics.number_of_invalid_children == 2 + assert export_req.validation_summary_statistics.number_of_unknown_children == 1 + + def test_fill_from_dict_without_validation_stats(self) -> None: + # GIVEN a response without validation summary statistics + response = { + "sessionId": SESSION_ID, + "recordSetId": RECORD_SET_ID, + "recordSetVersionNumber": 1, + } + + # WHEN I fill a GridRecordSetExportRequest from the response + export_req = GridRecordSetExportRequest(session_id=SESSION_ID) + export_req.fill_from_dict(response) + + # THEN the validation_summary_statistics should be None + assert export_req.response_session_id == SESSION_ID + assert export_req.validation_summary_statistics is None + + def test_to_synapse_request(self) -> None: + # GIVEN a GridRecordSetExportRequest + export_req = GridRecordSetExportRequest(session_id=SESSION_ID) + + # WHEN I convert it to a synapse request + result = export_req.to_synapse_request() + + # THEN it should contain the correct fields + assert "concreteType" in result + assert result["sessionId"] == SESSION_ID diff --git a/tests/unit/synapseclient/models/async/unit_test_evaluation_async.py b/tests/unit/synapseclient/models/async/unit_test_evaluation_async.py new file mode 100644 index 000000000..9024c522c --- /dev/null +++ b/tests/unit/synapseclient/models/async/unit_test_evaluation_async.py @@ -0,0 +1,1340 @@ +"""Async unit tests for the synapseclient.models.Evaluation class.""" + +from copy import deepcopy +from typing import Dict, List, Union +from unittest.mock import AsyncMock, patch + +import pytest + +from synapseclient import Synapse +from synapseclient.models.evaluation import Evaluation, RequestType + +EVALUATION_ID = "9614112" +EVALUATION_NAME = "My Challenge Evaluation" +EVALUATION_DESCRIPTION = "Evaluation for my data challenge" +EVALUATION_ETAG = "etag-abc-123" +OWNER_ID = "123456" +CREATED_ON = "2023-01-01T10:00:00.000Z" +CONTENT_SOURCE = "syn123456" +SUBMISSION_INSTRUCTIONS = "Submit CSV files only" +SUBMISSION_RECEIPT = "Thank you for your submission!" +PROJECT_ID = "syn789012" +PRINCIPAL_ID = "999888" +TEAM_PRINCIPAL_ID = "777666" + + +class TestEvaluationAsync: + """Async tests for the synapseclient.models.Evaluation class.""" + + @pytest.fixture(autouse=True, scope="function") + def init_syn(self, syn: Synapse) -> None: + self.syn = syn + + def get_example_evaluation_response(self) -> Dict[str, str]: + """Get a complete example evaluation response from the REST API.""" + return { + "id": EVALUATION_ID, + "etag": EVALUATION_ETAG, + "name": EVALUATION_NAME, + "description": EVALUATION_DESCRIPTION, + "ownerId": OWNER_ID, + "createdOn": CREATED_ON, + "contentSource": CONTENT_SOURCE, + "submissionInstructionsMessage": SUBMISSION_INSTRUCTIONS, + "submissionReceiptMessage": SUBMISSION_RECEIPT, + } + + def get_minimal_evaluation_response(self) -> Dict[str, str]: + """Get a minimal example evaluation response from the REST API.""" + return { + "id": EVALUATION_ID, + "etag": EVALUATION_ETAG, + "name": EVALUATION_NAME, + } + + def get_example_acl_response(self) -> Dict[str, Union[str, List]]: + """Get an example ACL response from the REST API.""" + return { + "id": EVALUATION_ID, + "resourceAccess": [ + { + "principalId": int(OWNER_ID), + "accessType": [ + "READ", + "UPDATE", + "DELETE", + "CHANGE_PERMISSIONS", + ], + }, + ], + "etag": EVALUATION_ETAG, + } + + def get_example_permissions_response(self) -> Dict[str, Union[str, List, bool]]: + """Get an example permissions response from the REST API.""" + return { + "canPublicRead": False, + "ownerPrincipalId": int(OWNER_ID), + "canView": True, + "canEdit": True, + "canMove": False, + "canAddChild": False, + "canCertifiedUserEdit": True, + "canCertifiedUserAddChild": False, + "isCertifiedUser": True, + "canChangePermissions": True, + "canChangeSettings": True, + "canDelete": True, + "canDownload": True, + "canUpload": False, + "canEnableInheritance": False, + "canModerate": False, + } + + def _build_evaluation_with_all_fields(self) -> Evaluation: + """Build an Evaluation instance with all required fields for store.""" + return Evaluation( + name=EVALUATION_NAME, + description=EVALUATION_DESCRIPTION, + content_source=CONTENT_SOURCE, + submission_instructions_message=SUBMISSION_INSTRUCTIONS, + submission_receipt_message=SUBMISSION_RECEIPT, + ) + + # ------------------------------------------------------------------ # + # fill_from_dict + # ------------------------------------------------------------------ # + + def test_fill_from_dict_complete_data(self) -> None: + # GIVEN a complete evaluation response from the REST API + response = self.get_example_evaluation_response() + + # WHEN I call fill_from_dict with the example evaluation response + evaluation = Evaluation().fill_from_dict(response) + + # THEN the Evaluation object should be filled with all the data + assert evaluation.id == EVALUATION_ID + assert evaluation.etag == EVALUATION_ETAG + assert evaluation.name == EVALUATION_NAME + assert evaluation.description == EVALUATION_DESCRIPTION + assert evaluation.owner_id == OWNER_ID + assert evaluation.created_on == CREATED_ON + assert evaluation.content_source == CONTENT_SOURCE + assert evaluation.submission_instructions_message == SUBMISSION_INSTRUCTIONS + assert evaluation.submission_receipt_message == SUBMISSION_RECEIPT + + def test_fill_from_dict_minimal_data(self) -> None: + # GIVEN a minimal evaluation response from the REST API + response = self.get_minimal_evaluation_response() + + # WHEN I call fill_from_dict with only required fields + evaluation = Evaluation().fill_from_dict(response) + + # THEN the Evaluation object should have set fields and None for missing + assert evaluation.id == EVALUATION_ID + assert evaluation.etag == EVALUATION_ETAG + assert evaluation.name == EVALUATION_NAME + assert evaluation.description is None + assert evaluation.owner_id is None + assert evaluation.created_on is None + assert evaluation.content_source is None + assert evaluation.submission_instructions_message is None + assert evaluation.submission_receipt_message is None + + def test_fill_from_dict_empty_dict(self) -> None: + # GIVEN an empty dictionary + # WHEN I call fill_from_dict with an empty dict + evaluation = Evaluation().fill_from_dict({}) + + # THEN all fields should be None + assert evaluation.id is None + assert evaluation.etag is None + assert evaluation.name is None + + # ------------------------------------------------------------------ # + # to_synapse_request + # ------------------------------------------------------------------ # + + def test_to_synapse_request_create(self) -> None: + # GIVEN an Evaluation with all required fields for creation + evaluation = self._build_evaluation_with_all_fields() + + # WHEN I call to_synapse_request with CREATE type + request_body = evaluation.to_synapse_request(request_type=RequestType.CREATE) + + # THEN the request body should contain all required fields + assert request_body["name"] == EVALUATION_NAME + assert request_body["description"] == EVALUATION_DESCRIPTION + assert request_body["contentSource"] == CONTENT_SOURCE + assert request_body["submissionInstructionsMessage"] == SUBMISSION_INSTRUCTIONS + assert request_body["submissionReceiptMessage"] == SUBMISSION_RECEIPT + # AND should not contain id or etag for CREATE + assert "id" not in request_body + assert "etag" not in request_body + + def test_to_synapse_request_update(self) -> None: + # GIVEN an Evaluation with all required fields for update including id and etag + evaluation = self._build_evaluation_with_all_fields() + evaluation.id = EVALUATION_ID + evaluation.etag = EVALUATION_ETAG + + # WHEN I call to_synapse_request with UPDATE type + request_body = evaluation.to_synapse_request(request_type=RequestType.UPDATE) + + # THEN the request body should contain all fields including id and etag + assert request_body["id"] == EVALUATION_ID + assert request_body["etag"] == EVALUATION_ETAG + assert request_body["name"] == EVALUATION_NAME + assert request_body["description"] == EVALUATION_DESCRIPTION + + def test_to_synapse_request_create_missing_field_raises_value_error(self) -> None: + # GIVEN an Evaluation missing a required field (description) + evaluation = Evaluation( + name=EVALUATION_NAME, + content_source=CONTENT_SOURCE, + submission_instructions_message=SUBMISSION_INSTRUCTIONS, + submission_receipt_message=SUBMISSION_RECEIPT, + ) + + # WHEN I call to_synapse_request with CREATE type + # THEN a ValueError should be raised + with pytest.raises(ValueError, match="description"): + evaluation.to_synapse_request(request_type=RequestType.CREATE) + + def test_to_synapse_request_update_missing_id_raises_value_error(self) -> None: + # GIVEN an Evaluation with all CREATE fields but missing id for UPDATE + evaluation = self._build_evaluation_with_all_fields() + evaluation.etag = EVALUATION_ETAG + + # WHEN I call to_synapse_request with UPDATE type + # THEN a ValueError should be raised for missing id + with pytest.raises(ValueError, match="id"): + evaluation.to_synapse_request(request_type=RequestType.UPDATE) + + # ------------------------------------------------------------------ # + # has_changed + # ------------------------------------------------------------------ # + + def test_has_changed_no_persistent_instance(self) -> None: + # GIVEN a brand new Evaluation without any persistent instance + evaluation = Evaluation(name=EVALUATION_NAME) + + # WHEN I check has_changed + # THEN it should return True because there is no last persistent instance + assert evaluation.has_changed is True + + def test_has_changed_after_set_persistent_instance(self) -> None: + # GIVEN an Evaluation that has been persisted + evaluation = Evaluation(name=EVALUATION_NAME) + evaluation._set_last_persistent_instance() + + # WHEN I check has_changed without modifying anything + # THEN it should return False + assert evaluation.has_changed is False + + def test_has_changed_after_modification(self) -> None: + # GIVEN an Evaluation that has been persisted + evaluation = Evaluation(name=EVALUATION_NAME) + evaluation._set_last_persistent_instance() + + # WHEN I modify a field + evaluation.name = "Modified Name" + + # THEN has_changed should return True + assert evaluation.has_changed is True + + # ------------------------------------------------------------------ # + # store_async - create (no ID) + # ------------------------------------------------------------------ # + + @pytest.mark.asyncio + async def test_store_async_create_new_evaluation(self) -> None: + # GIVEN an Evaluation with required fields but no ID (new evaluation) + evaluation = self._build_evaluation_with_all_fields() + + # WHEN I call store_async with a mocked API response + with patch( + "synapseclient.api.evaluation_services.create_or_update_evaluation", + new_callable=AsyncMock, + return_value=self.get_example_evaluation_response(), + ) as mock_create: + result = await evaluation.store_async(synapse_client=self.syn) + + # THEN the API should be called with the CREATE request body + mock_create.assert_called_once() + call_kwargs = mock_create.call_args[1] + assert "id" not in call_kwargs["request_body"] + assert call_kwargs["request_body"]["name"] == EVALUATION_NAME + + # AND the evaluation should be populated from the response + assert result.id == EVALUATION_ID + assert result.etag == EVALUATION_ETAG + assert result.name == EVALUATION_NAME + assert result.owner_id == OWNER_ID + assert result.created_on == CREATED_ON + + # AND the persistent instance should be set + assert result._last_persistent_instance is not None + assert result.has_changed is False + + # ------------------------------------------------------------------ # + # store_async - update (has ID, has_changed=True) + # ------------------------------------------------------------------ # + + @pytest.mark.asyncio + async def test_store_async_update_existing_evaluation(self) -> None: + # GIVEN an Evaluation that was previously retrieved from Synapse + evaluation = self._build_evaluation_with_all_fields() + evaluation.id = EVALUATION_ID + evaluation.etag = EVALUATION_ETAG + evaluation.owner_id = OWNER_ID + evaluation.created_on = CREATED_ON + evaluation._set_last_persistent_instance() + + # AND the description has been changed + evaluation.description = "Updated description" + + updated_response = self.get_example_evaluation_response() + updated_response["description"] = "Updated description" + + # WHEN I call store_async + with patch( + "synapseclient.api.evaluation_services.create_or_update_evaluation", + new_callable=AsyncMock, + return_value=updated_response, + ) as mock_update: + result = await evaluation.store_async(synapse_client=self.syn) + + # THEN the API should be called with the UPDATE request body + mock_update.assert_called_once() + call_kwargs = mock_update.call_args[1] + assert call_kwargs["request_body"]["id"] == EVALUATION_ID + assert call_kwargs["request_body"]["etag"] == EVALUATION_ETAG + + # AND the evaluation should be updated + assert result.description == "Updated description" + assert result.has_changed is False + + # ------------------------------------------------------------------ # + # store_async - skip update (has_changed=False) + # ------------------------------------------------------------------ # + + @pytest.mark.asyncio + async def test_store_async_skip_update_when_not_changed(self) -> None: + # GIVEN an Evaluation that was previously retrieved and has NOT been modified + evaluation = self._build_evaluation_with_all_fields() + evaluation.id = EVALUATION_ID + evaluation.etag = EVALUATION_ETAG + evaluation._set_last_persistent_instance() + + # WHEN I call store_async without any changes + with patch( + "synapseclient.api.evaluation_services.create_or_update_evaluation", + new_callable=AsyncMock, + ) as mock_api: + result = await evaluation.store_async(synapse_client=self.syn) + + # THEN the API should NOT be called + mock_api.assert_not_called() + + # AND the same evaluation should be returned unchanged + assert result is evaluation + assert result.id == EVALUATION_ID + + # ------------------------------------------------------------------ # + # get_async - by ID + # ------------------------------------------------------------------ # + + @pytest.mark.asyncio + async def test_get_async_by_id(self) -> None: + # GIVEN an Evaluation with an ID set + evaluation = Evaluation(id=EVALUATION_ID) + + # WHEN I call get_async with a mocked API response + with patch( + "synapseclient.api.evaluation_services.get_evaluation", + new_callable=AsyncMock, + return_value=self.get_example_evaluation_response(), + ) as mock_get: + result = await evaluation.get_async(synapse_client=self.syn) + + # THEN the API should be called with the evaluation ID + mock_get.assert_called_once_with( + evaluation_id=EVALUATION_ID, + name=None, + synapse_client=self.syn, + ) + + # AND the evaluation should be populated from the response + assert result.id == EVALUATION_ID + assert result.name == EVALUATION_NAME + assert result.description == EVALUATION_DESCRIPTION + assert result.owner_id == OWNER_ID + + # AND the persistent instance should be set + assert result._last_persistent_instance is not None + assert result.has_changed is False + + # ------------------------------------------------------------------ # + # get_async - by name + # ------------------------------------------------------------------ # + + @pytest.mark.asyncio + async def test_get_async_by_name(self) -> None: + # GIVEN an Evaluation with a name set but no ID + evaluation = Evaluation(name=EVALUATION_NAME) + + # WHEN I call get_async with a mocked API response + with patch( + "synapseclient.api.evaluation_services.get_evaluation", + new_callable=AsyncMock, + return_value=self.get_example_evaluation_response(), + ) as mock_get: + result = await evaluation.get_async(synapse_client=self.syn) + + # THEN the API should be called with the name + mock_get.assert_called_once_with( + evaluation_id=None, + name=EVALUATION_NAME, + synapse_client=self.syn, + ) + + # AND the evaluation should be populated from the response + assert result.id == EVALUATION_ID + assert result.name == EVALUATION_NAME + + # ------------------------------------------------------------------ # + # get_async - missing both ID and name raises ValueError + # ------------------------------------------------------------------ # + + @pytest.mark.asyncio + async def test_get_async_missing_id_and_name_raises_value_error(self) -> None: + # GIVEN an Evaluation with neither ID nor name + evaluation = Evaluation() + + # WHEN I call get_async + # THEN a ValueError should be raised + with pytest.raises( + ValueError, match="Either id or name must be set to get an evaluation" + ): + await evaluation.get_async(synapse_client=self.syn) + + # ------------------------------------------------------------------ # + # delete_async - with ID + # ------------------------------------------------------------------ # + + @pytest.mark.asyncio + async def test_delete_async_with_id(self) -> None: + # GIVEN an Evaluation with an ID and a persistent instance + evaluation = Evaluation(id=EVALUATION_ID, name=EVALUATION_NAME) + evaluation._set_last_persistent_instance() + + # WHEN I call delete_async + with patch( + "synapseclient.api.evaluation_services.delete_evaluation", + new_callable=AsyncMock, + ) as mock_delete: + await evaluation.delete_async(synapse_client=self.syn) + + # THEN the API should be called with the evaluation ID + mock_delete.assert_called_once_with( + evaluation_id=EVALUATION_ID, + synapse_client=self.syn, + ) + + # AND the persistent instance should be cleared + assert evaluation._last_persistent_instance is None + + # ------------------------------------------------------------------ # + # delete_async - missing ID raises ValueError + # ------------------------------------------------------------------ # + + @pytest.mark.asyncio + async def test_delete_async_missing_id_raises_value_error(self) -> None: + # GIVEN an Evaluation with no ID + evaluation = Evaluation(name=EVALUATION_NAME) + + # WHEN I call delete_async + # THEN a ValueError should be raised + with pytest.raises(ValueError, match="id must be set to delete an evaluation"): + await evaluation.delete_async(synapse_client=self.syn) + + # ------------------------------------------------------------------ # + # get_acl_async - success + # ------------------------------------------------------------------ # + + @pytest.mark.asyncio + async def test_get_acl_async_success(self) -> None: + # GIVEN an Evaluation with an ID + evaluation = Evaluation(id=EVALUATION_ID) + expected_acl = self.get_example_acl_response() + + # WHEN I call get_acl_async + with patch( + "synapseclient.api.evaluation_services.get_evaluation_acl", + new_callable=AsyncMock, + return_value=expected_acl, + ) as mock_get_acl: + result = await evaluation.get_acl_async(synapse_client=self.syn) + + # THEN the API should be called with the evaluation ID + mock_get_acl.assert_called_once_with( + evaluation_id=EVALUATION_ID, + synapse_client=self.syn, + ) + + # AND the ACL should be returned + assert result["id"] == EVALUATION_ID + assert len(result["resourceAccess"]) == 1 + assert result["resourceAccess"][0]["principalId"] == int(OWNER_ID) + + # ------------------------------------------------------------------ # + # get_acl_async - missing ID raises ValueError + # ------------------------------------------------------------------ # + + @pytest.mark.asyncio + async def test_get_acl_async_missing_id_raises_value_error(self) -> None: + # GIVEN an Evaluation with no ID + evaluation = Evaluation(name=EVALUATION_NAME) + + # WHEN I call get_acl_async + # THEN a ValueError should be raised + with pytest.raises(ValueError, match="id must be set to get evaluation ACL"): + await evaluation.get_acl_async(synapse_client=self.syn) + + # ------------------------------------------------------------------ # + # update_acl_async - with principal_id and access_type + # ------------------------------------------------------------------ # + + @pytest.mark.asyncio + async def test_update_acl_async_with_principal_id_and_access_type(self) -> None: + # GIVEN an Evaluation with an ID + evaluation = Evaluation(id=EVALUATION_ID) + current_acl = self.get_example_acl_response() + updated_acl = deepcopy(current_acl) + updated_acl["resourceAccess"].append( + {"principalId": int(PRINCIPAL_ID), "accessType": ["READ", "SUBMIT"]} + ) + + # WHEN I call update_acl_async with a principal_id and access_type + with patch( + "synapseclient.api.evaluation_services.get_evaluation_acl", + new_callable=AsyncMock, + return_value=current_acl, + ) as mock_get_acl, patch( + "synapseclient.api.evaluation_services.update_evaluation_acl", + new_callable=AsyncMock, + return_value=updated_acl, + ) as mock_update_acl: + result = await evaluation.update_acl_async( + principal_id=PRINCIPAL_ID, + access_type=["READ", "SUBMIT"], + synapse_client=self.syn, + ) + + # THEN get_acl should be called first to fetch current ACL + mock_get_acl.assert_called_once_with( + evaluation_id=EVALUATION_ID, + synapse_client=self.syn, + ) + + # AND update_acl should be called with the modified ACL + mock_update_acl.assert_called_once() + call_kwargs = mock_update_acl.call_args[1] + acl_sent = call_kwargs["acl"] + # The new principal should be in the resourceAccess + principal_ids = [ra["principalId"] for ra in acl_sent["resourceAccess"]] + assert int(PRINCIPAL_ID) in principal_ids + + # AND the updated ACL should be returned + assert result == updated_acl + + # ------------------------------------------------------------------ # + # update_acl_async - with full acl dict + # ------------------------------------------------------------------ # + + @pytest.mark.asyncio + async def test_update_acl_async_with_full_acl_dict(self) -> None: + # GIVEN an Evaluation with an ID + evaluation = Evaluation(id=EVALUATION_ID) + full_acl = self.get_example_acl_response() + full_acl["resourceAccess"].append( + {"principalId": int(PRINCIPAL_ID), "accessType": ["READ"]} + ) + + # WHEN I call update_acl_async with a complete ACL dictionary + with patch( + "synapseclient.api.evaluation_services.update_evaluation_acl", + new_callable=AsyncMock, + return_value=full_acl, + ) as mock_update_acl: + result = await evaluation.update_acl_async( + acl=full_acl, + synapse_client=self.syn, + ) + + # THEN the API should be called directly with the provided ACL + mock_update_acl.assert_called_once_with( + acl=full_acl, + synapse_client=self.syn, + ) + + # AND get_acl should NOT be called (no need to fetch current ACL) + # AND the updated ACL should be returned + assert result == full_acl + + # ------------------------------------------------------------------ # + # update_acl_async - add new principal + # ------------------------------------------------------------------ # + + @pytest.mark.asyncio + async def test_update_acl_async_adds_new_principal(self) -> None: + # GIVEN an Evaluation with an ID and an existing ACL with one principal + evaluation = Evaluation(id=EVALUATION_ID) + current_acl = self.get_example_acl_response() + + expected_updated_acl = deepcopy(current_acl) + expected_updated_acl["resourceAccess"].append( + {"principalId": int(TEAM_PRINCIPAL_ID), "accessType": ["READ", "SUBMIT"]} + ) + + # WHEN I call update_acl_async with a NEW principal_id + with patch( + "synapseclient.api.evaluation_services.get_evaluation_acl", + new_callable=AsyncMock, + return_value=current_acl, + ), patch( + "synapseclient.api.evaluation_services.update_evaluation_acl", + new_callable=AsyncMock, + return_value=expected_updated_acl, + ) as mock_update_acl: + result = await evaluation.update_acl_async( + principal_id=TEAM_PRINCIPAL_ID, + access_type=["READ", "SUBMIT"], + synapse_client=self.syn, + ) + + # THEN the ACL passed to update should contain the new principal + call_kwargs = mock_update_acl.call_args[1] + acl_sent = call_kwargs["acl"] + assert len(acl_sent["resourceAccess"]) == 2 + new_entry = [ + ra + for ra in acl_sent["resourceAccess"] + if ra["principalId"] == int(TEAM_PRINCIPAL_ID) + ] + assert len(new_entry) == 1 + assert new_entry[0]["accessType"] == ["READ", "SUBMIT"] + + # ------------------------------------------------------------------ # + # update_acl_async - update existing principal + # ------------------------------------------------------------------ # + + @pytest.mark.asyncio + async def test_update_acl_async_updates_existing_principal(self) -> None: + # GIVEN an Evaluation with an existing ACL containing the OWNER_ID principal + evaluation = Evaluation(id=EVALUATION_ID) + current_acl = self.get_example_acl_response() + + expected_updated_acl = deepcopy(current_acl) + expected_updated_acl["resourceAccess"][0]["accessType"] = ["READ"] + + # WHEN I call update_acl_async to update the existing principal's permissions + with patch( + "synapseclient.api.evaluation_services.get_evaluation_acl", + new_callable=AsyncMock, + return_value=current_acl, + ), patch( + "synapseclient.api.evaluation_services.update_evaluation_acl", + new_callable=AsyncMock, + return_value=expected_updated_acl, + ) as mock_update_acl: + result = await evaluation.update_acl_async( + principal_id=OWNER_ID, + access_type=["READ"], + synapse_client=self.syn, + ) + + # THEN the ACL sent to the API should have updated permissions for the owner + call_kwargs = mock_update_acl.call_args[1] + acl_sent = call_kwargs["acl"] + owner_entry = [ + ra + for ra in acl_sent["resourceAccess"] + if ra["principalId"] == int(OWNER_ID) + ] + assert len(owner_entry) == 1 + assert owner_entry[0]["accessType"] == ["READ"] + + # AND should still have only one resource access entry + assert len(acl_sent["resourceAccess"]) == 1 + + # ------------------------------------------------------------------ # + # update_acl_async - remove principal (empty access_type) + # ------------------------------------------------------------------ # + + @pytest.mark.asyncio + async def test_update_acl_async_removes_principal_with_empty_access_type( + self, + ) -> None: + # GIVEN an Evaluation with an existing ACL containing the OWNER_ID principal + evaluation = Evaluation(id=EVALUATION_ID) + current_acl = self.get_example_acl_response() + + expected_updated_acl = deepcopy(current_acl) + expected_updated_acl["resourceAccess"] = [] + + # WHEN I call update_acl_async with an empty access_type list + with patch( + "synapseclient.api.evaluation_services.get_evaluation_acl", + new_callable=AsyncMock, + return_value=current_acl, + ), patch( + "synapseclient.api.evaluation_services.update_evaluation_acl", + new_callable=AsyncMock, + return_value=expected_updated_acl, + ) as mock_update_acl: + result = await evaluation.update_acl_async( + principal_id=OWNER_ID, + access_type=[], + synapse_client=self.syn, + ) + + # THEN the ACL sent to the API should have the principal removed + call_kwargs = mock_update_acl.call_args[1] + acl_sent = call_kwargs["acl"] + owner_entries = [ + ra + for ra in acl_sent["resourceAccess"] + if ra["principalId"] == int(OWNER_ID) + ] + assert len(owner_entries) == 0 + + # ------------------------------------------------------------------ # + # update_acl_async - missing ID raises ValueError + # ------------------------------------------------------------------ # + + @pytest.mark.asyncio + async def test_update_acl_async_missing_id_raises_value_error(self) -> None: + # GIVEN an Evaluation with no ID + evaluation = Evaluation(name=EVALUATION_NAME) + + # WHEN I call update_acl_async + # THEN a ValueError should be raised + with pytest.raises(ValueError, match="id must be set to update evaluation ACL"): + await evaluation.update_acl_async( + principal_id=PRINCIPAL_ID, + access_type=["READ"], + synapse_client=self.syn, + ) + + # ------------------------------------------------------------------ # + # update_acl_async - missing both principal and acl raises ValueError + # ------------------------------------------------------------------ # + + @pytest.mark.asyncio + async def test_update_acl_async_missing_principal_and_acl_raises_value_error( + self, + ) -> None: + # GIVEN an Evaluation with an ID + evaluation = Evaluation(id=EVALUATION_ID) + + # WHEN I call update_acl_async without principal_id, access_type, or acl + # THEN a ValueError should be raised + with pytest.raises( + ValueError, + match="Either \\(principal_id and access_type\\) or acl must be provided", + ): + await evaluation.update_acl_async(synapse_client=self.syn) + + # ------------------------------------------------------------------ # + # update_acl_async - access_type uppercased + # ------------------------------------------------------------------ # + + @pytest.mark.asyncio + async def test_update_acl_async_uppercases_access_type(self) -> None: + # GIVEN an Evaluation with an ID + evaluation = Evaluation(id=EVALUATION_ID) + current_acl = self.get_example_acl_response() + + # WHEN I call update_acl_async with lowercase access_type values + with patch( + "synapseclient.api.evaluation_services.get_evaluation_acl", + new_callable=AsyncMock, + return_value=current_acl, + ), patch( + "synapseclient.api.evaluation_services.update_evaluation_acl", + new_callable=AsyncMock, + return_value=current_acl, + ) as mock_update_acl: + await evaluation.update_acl_async( + principal_id=TEAM_PRINCIPAL_ID, + access_type=["read", "submit"], + synapse_client=self.syn, + ) + + # THEN the access_type in the ACL should be uppercased + call_kwargs = mock_update_acl.call_args[1] + acl_sent = call_kwargs["acl"] + new_entry = [ + ra + for ra in acl_sent["resourceAccess"] + if ra["principalId"] == int(TEAM_PRINCIPAL_ID) + ] + assert len(new_entry) == 1 + assert new_entry[0]["accessType"] == ["READ", "SUBMIT"] + + # ------------------------------------------------------------------ # + # get_permissions_async - success + # ------------------------------------------------------------------ # + + @pytest.mark.asyncio + async def test_get_permissions_async_success(self) -> None: + # GIVEN an Evaluation with an ID + evaluation = Evaluation(id=EVALUATION_ID) + expected_permissions = self.get_example_permissions_response() + + # WHEN I call get_permissions_async + with patch( + "synapseclient.api.evaluation_services.get_evaluation_permissions", + new_callable=AsyncMock, + return_value=expected_permissions, + ) as mock_get_perms: + result = await evaluation.get_permissions_async(synapse_client=self.syn) + + # THEN the API should be called with the evaluation ID + mock_get_perms.assert_called_once_with( + evaluation_id=EVALUATION_ID, + synapse_client=self.syn, + ) + + # AND the permissions should be returned + assert result["canView"] is True + assert result["canEdit"] is True + assert result["canDelete"] is True + assert result["canChangePermissions"] is True + + # ------------------------------------------------------------------ # + # get_permissions_async - missing ID raises ValueError + # ------------------------------------------------------------------ # + + @pytest.mark.asyncio + async def test_get_permissions_async_missing_id_raises_value_error(self) -> None: + # GIVEN an Evaluation with no ID + evaluation = Evaluation(name=EVALUATION_NAME) + + # WHEN I call get_permissions_async + # THEN a ValueError should be raised + with pytest.raises( + ValueError, match="id must be set to get evaluation permissions" + ): + await evaluation.get_permissions_async(synapse_client=self.syn) + + # ------------------------------------------------------------------ # + # get_all_evaluations_async - static method with pagination params + # ------------------------------------------------------------------ # + + @pytest.mark.asyncio + async def test_get_all_evaluations_async_default(self) -> None: + # GIVEN a mocked API that returns a paginated result with two evaluations + eval_response_1 = self.get_example_evaluation_response() + eval_response_2 = self.get_example_evaluation_response() + eval_response_2["id"] = "9614113" + eval_response_2["name"] = "Second Evaluation" + + api_response = { + "results": [eval_response_1, eval_response_2], + "totalNumberOfResults": 2, + } + + # WHEN I call get_all_evaluations_async with no parameters + with patch( + "synapseclient.api.evaluation_services.get_all_evaluations", + new_callable=AsyncMock, + return_value=api_response, + ) as mock_get_all: + results = await Evaluation.get_all_evaluations_async( + synapse_client=self.syn + ) + + # THEN the API should be called with default parameters + mock_get_all.assert_called_once_with( + access_type=None, + active_only=None, + evaluation_ids=None, + offset=None, + limit=None, + synapse_client=self.syn, + ) + + # AND the results should be a list of Evaluation objects + assert len(results) == 2 + assert isinstance(results[0], Evaluation) + assert results[0].id == EVALUATION_ID + assert results[0].name == EVALUATION_NAME + assert isinstance(results[1], Evaluation) + assert results[1].id == "9614113" + assert results[1].name == "Second Evaluation" + + @pytest.mark.asyncio + async def test_get_all_evaluations_async_with_pagination_params(self) -> None: + # GIVEN a mocked API that returns a single evaluation + api_response = { + "results": [self.get_example_evaluation_response()], + "totalNumberOfResults": 1, + } + + # WHEN I call get_all_evaluations_async with pagination and filter params + with patch( + "synapseclient.api.evaluation_services.get_all_evaluations", + new_callable=AsyncMock, + return_value=api_response, + ) as mock_get_all: + results = await Evaluation.get_all_evaluations_async( + access_type="SUBMIT", + active_only=True, + evaluation_ids=[EVALUATION_ID], + offset=5, + limit=20, + synapse_client=self.syn, + ) + + # THEN the API should be called with the specified parameters + mock_get_all.assert_called_once_with( + access_type="SUBMIT", + active_only=True, + evaluation_ids=[EVALUATION_ID], + offset=5, + limit=20, + synapse_client=self.syn, + ) + + # AND the results should contain one Evaluation + assert len(results) == 1 + assert results[0].id == EVALUATION_ID + + @pytest.mark.asyncio + async def test_get_all_evaluations_async_empty_results(self) -> None: + # GIVEN a mocked API that returns an empty list + api_response = { + "results": [], + "totalNumberOfResults": 0, + } + + # WHEN I call get_all_evaluations_async + with patch( + "synapseclient.api.evaluation_services.get_all_evaluations", + new_callable=AsyncMock, + return_value=api_response, + ): + results = await Evaluation.get_all_evaluations_async( + synapse_client=self.syn + ) + + # THEN the results should be an empty list + assert results == [] + + # ------------------------------------------------------------------ # + # get_available_evaluations_async - static method + # ------------------------------------------------------------------ # + + @pytest.mark.asyncio + async def test_get_available_evaluations_async_default(self) -> None: + # GIVEN a mocked API that returns a list of available evaluations + api_response = { + "results": [self.get_example_evaluation_response()], + "totalNumberOfResults": 1, + } + + # WHEN I call get_available_evaluations_async + with patch( + "synapseclient.api.evaluation_services.get_available_evaluations", + new_callable=AsyncMock, + return_value=api_response, + ) as mock_get_available: + results = await Evaluation.get_available_evaluations_async( + synapse_client=self.syn + ) + + # THEN the API should be called with default parameters + mock_get_available.assert_called_once_with( + active_only=None, + evaluation_ids=None, + offset=None, + limit=None, + synapse_client=self.syn, + ) + + # AND the results should contain Evaluation objects + assert len(results) == 1 + assert isinstance(results[0], Evaluation) + assert results[0].id == EVALUATION_ID + + @pytest.mark.asyncio + async def test_get_available_evaluations_async_with_params(self) -> None: + # GIVEN a mocked API response + api_response = { + "results": [self.get_example_evaluation_response()], + "totalNumberOfResults": 1, + } + + # WHEN I call get_available_evaluations_async with filtering parameters + with patch( + "synapseclient.api.evaluation_services.get_available_evaluations", + new_callable=AsyncMock, + return_value=api_response, + ) as mock_get_available: + results = await Evaluation.get_available_evaluations_async( + active_only=True, + evaluation_ids=[EVALUATION_ID], + offset=0, + limit=5, + synapse_client=self.syn, + ) + + # THEN the API should be called with the specified parameters + mock_get_available.assert_called_once_with( + active_only=True, + evaluation_ids=[EVALUATION_ID], + offset=0, + limit=5, + synapse_client=self.syn, + ) + + assert len(results) == 1 + + @pytest.mark.asyncio + async def test_get_available_evaluations_async_empty_results(self) -> None: + # GIVEN a mocked API that returns an empty list + api_response = { + "results": [], + "totalNumberOfResults": 0, + } + + # WHEN I call get_available_evaluations_async + with patch( + "synapseclient.api.evaluation_services.get_available_evaluations", + new_callable=AsyncMock, + return_value=api_response, + ): + results = await Evaluation.get_available_evaluations_async( + synapse_client=self.syn + ) + + # THEN the results should be an empty list + assert results == [] + + # ------------------------------------------------------------------ # + # get_evaluations_by_project_async - static method with project_id + # ------------------------------------------------------------------ # + + @pytest.mark.asyncio + async def test_get_evaluations_by_project_async_default(self) -> None: + # GIVEN a mocked API that returns evaluations for a project + api_response = { + "results": [self.get_example_evaluation_response()], + "totalNumberOfResults": 1, + } + + # WHEN I call get_evaluations_by_project_async + with patch( + "synapseclient.api.evaluation_services.get_evaluations_by_project", + new_callable=AsyncMock, + return_value=api_response, + ) as mock_get_by_project: + results = await Evaluation.get_evaluations_by_project_async( + project_id=PROJECT_ID, + synapse_client=self.syn, + ) + + # THEN the API should be called with the project_id and defaults + mock_get_by_project.assert_called_once_with( + project_id=PROJECT_ID, + access_type=None, + active_only=None, + evaluation_ids=None, + offset=None, + limit=None, + synapse_client=self.syn, + ) + + # AND the results should contain Evaluation objects + assert len(results) == 1 + assert isinstance(results[0], Evaluation) + assert results[0].id == EVALUATION_ID + assert results[0].content_source == CONTENT_SOURCE + + @pytest.mark.asyncio + async def test_get_evaluations_by_project_async_with_params(self) -> None: + # GIVEN a mocked API response + api_response = { + "results": [self.get_example_evaluation_response()], + "totalNumberOfResults": 1, + } + + # WHEN I call get_evaluations_by_project_async with all optional params + with patch( + "synapseclient.api.evaluation_services.get_evaluations_by_project", + new_callable=AsyncMock, + return_value=api_response, + ) as mock_get_by_project: + results = await Evaluation.get_evaluations_by_project_async( + project_id=PROJECT_ID, + access_type="SUBMIT", + active_only=True, + evaluation_ids=[EVALUATION_ID], + offset=10, + limit=50, + synapse_client=self.syn, + ) + + # THEN the API should be called with all specified parameters + mock_get_by_project.assert_called_once_with( + project_id=PROJECT_ID, + access_type="SUBMIT", + active_only=True, + evaluation_ids=[EVALUATION_ID], + offset=10, + limit=50, + synapse_client=self.syn, + ) + + assert len(results) == 1 + + @pytest.mark.asyncio + async def test_get_evaluations_by_project_async_empty_results(self) -> None: + # GIVEN a mocked API that returns an empty list for the project + api_response = { + "results": [], + "totalNumberOfResults": 0, + } + + # WHEN I call get_evaluations_by_project_async + with patch( + "synapseclient.api.evaluation_services.get_evaluations_by_project", + new_callable=AsyncMock, + return_value=api_response, + ): + results = await Evaluation.get_evaluations_by_project_async( + project_id=PROJECT_ID, + synapse_client=self.syn, + ) + + # THEN the results should be an empty list + assert results == [] + + @pytest.mark.asyncio + async def test_get_evaluations_by_project_async_multiple_results(self) -> None: + # GIVEN a mocked API that returns multiple evaluations for a project + eval_response_1 = self.get_example_evaluation_response() + eval_response_2 = self.get_example_evaluation_response() + eval_response_2["id"] = "9614113" + eval_response_2["name"] = "Second Project Evaluation" + + api_response = { + "results": [eval_response_1, eval_response_2], + "totalNumberOfResults": 2, + } + + # WHEN I call get_evaluations_by_project_async + with patch( + "synapseclient.api.evaluation_services.get_evaluations_by_project", + new_callable=AsyncMock, + return_value=api_response, + ): + results = await Evaluation.get_evaluations_by_project_async( + project_id=PROJECT_ID, + synapse_client=self.syn, + ) + + # THEN the results should contain two Evaluation objects + assert len(results) == 2 + assert results[0].id == EVALUATION_ID + assert results[1].id == "9614113" + assert results[1].name == "Second Project Evaluation" + + # ------------------------------------------------------------------ # + # _update_acl_permissions (helper method) + # ------------------------------------------------------------------ # + + def test_update_acl_permissions_add_new_principal(self) -> None: + # GIVEN an Evaluation and an ACL with one existing principal + evaluation = Evaluation(id=EVALUATION_ID) + acl = self.get_example_acl_response() + + # WHEN I call _update_acl_permissions for a new principal + result = evaluation._update_acl_permissions( + principal_id=TEAM_PRINCIPAL_ID, + access_type=["READ", "SUBMIT"], + acl=acl, + synapse_client=self.syn, + ) + + # THEN the new principal should be added to resourceAccess + assert len(result["resourceAccess"]) == 2 + new_entry = [ + ra + for ra in result["resourceAccess"] + if ra["principalId"] == int(TEAM_PRINCIPAL_ID) + ] + assert len(new_entry) == 1 + assert new_entry[0]["accessType"] == ["READ", "SUBMIT"] + + def test_update_acl_permissions_update_existing_principal(self) -> None: + # GIVEN an Evaluation and an ACL with the OWNER_ID principal + evaluation = Evaluation(id=EVALUATION_ID) + acl = self.get_example_acl_response() + + # WHEN I call _update_acl_permissions to update the existing principal + result = evaluation._update_acl_permissions( + principal_id=OWNER_ID, + access_type=["READ"], + acl=acl, + synapse_client=self.syn, + ) + + # THEN the existing principal's access type should be updated + assert len(result["resourceAccess"]) == 1 + assert result["resourceAccess"][0]["accessType"] == ["READ"] + + def test_update_acl_permissions_remove_principal(self) -> None: + # GIVEN an Evaluation and an ACL with the OWNER_ID principal + evaluation = Evaluation(id=EVALUATION_ID) + acl = self.get_example_acl_response() + + # WHEN I call _update_acl_permissions with empty access_type + result = evaluation._update_acl_permissions( + principal_id=OWNER_ID, + access_type=[], + acl=acl, + synapse_client=self.syn, + ) + + # THEN the principal should be removed from resourceAccess + assert len(result["resourceAccess"]) == 0 + + def test_update_acl_permissions_remove_nonexistent_principal(self) -> None: + # GIVEN an Evaluation and an ACL with the OWNER_ID principal + evaluation = Evaluation(id=EVALUATION_ID) + acl = self.get_example_acl_response() + + # WHEN I call _update_acl_permissions to remove a principal that is not in the ACL + result = evaluation._update_acl_permissions( + principal_id=TEAM_PRINCIPAL_ID, + access_type=[], + acl=acl, + synapse_client=self.syn, + ) + + # THEN the ACL should remain unchanged (the non-existent principal is simply not there) + assert len(result["resourceAccess"]) == 1 + assert result["resourceAccess"][0]["principalId"] == int(OWNER_ID) + + # ------------------------------------------------------------------ # + # store_async - create populates all response fields + # ------------------------------------------------------------------ # + + @pytest.mark.asyncio + async def test_store_async_create_populates_all_fields(self) -> None: + # GIVEN an Evaluation with required fields for creation + evaluation = self._build_evaluation_with_all_fields() + + # WHEN I call store_async and the API returns a full response + with patch( + "synapseclient.api.evaluation_services.create_or_update_evaluation", + new_callable=AsyncMock, + return_value=self.get_example_evaluation_response(), + ): + result = await evaluation.store_async(synapse_client=self.syn) + + # THEN all fields should be populated from the API response + assert result.id == EVALUATION_ID + assert result.etag == EVALUATION_ETAG + assert result.name == EVALUATION_NAME + assert result.description == EVALUATION_DESCRIPTION + assert result.owner_id == OWNER_ID + assert result.created_on == CREATED_ON + assert result.content_source == CONTENT_SOURCE + assert result.submission_instructions_message == SUBMISSION_INSTRUCTIONS + assert result.submission_receipt_message == SUBMISSION_RECEIPT + + # ------------------------------------------------------------------ # + # store_async - returns self + # ------------------------------------------------------------------ # + + @pytest.mark.asyncio + async def test_store_async_returns_self(self) -> None: + # GIVEN an Evaluation object + evaluation = self._build_evaluation_with_all_fields() + + # WHEN I call store_async + with patch( + "synapseclient.api.evaluation_services.create_or_update_evaluation", + new_callable=AsyncMock, + return_value=self.get_example_evaluation_response(), + ): + result = await evaluation.store_async(synapse_client=self.syn) + + # THEN the result should be the same object (self) + assert result is evaluation + + # ------------------------------------------------------------------ # + # get_async - returns self + # ------------------------------------------------------------------ # + + @pytest.mark.asyncio + async def test_get_async_returns_self(self) -> None: + # GIVEN an Evaluation with an ID + evaluation = Evaluation(id=EVALUATION_ID) + + # WHEN I call get_async + with patch( + "synapseclient.api.evaluation_services.get_evaluation", + new_callable=AsyncMock, + return_value=self.get_example_evaluation_response(), + ): + result = await evaluation.get_async(synapse_client=self.syn) + + # THEN the result should be the same object (self) + assert result is evaluation + + # ------------------------------------------------------------------ # + # get_async - sets persistent instance for change tracking + # ------------------------------------------------------------------ # + + @pytest.mark.asyncio + async def test_get_async_sets_persistent_instance(self) -> None: + # GIVEN an Evaluation with an ID but no persistent instance + evaluation = Evaluation(id=EVALUATION_ID) + assert evaluation._last_persistent_instance is None + + # WHEN I call get_async + with patch( + "synapseclient.api.evaluation_services.get_evaluation", + new_callable=AsyncMock, + return_value=self.get_example_evaluation_response(), + ): + result = await evaluation.get_async(synapse_client=self.syn) + + # THEN the persistent instance should be set + assert result._last_persistent_instance is not None + assert result.has_changed is False + + # ------------------------------------------------------------------ # + # delete_async - returns None + # ------------------------------------------------------------------ # + + @pytest.mark.asyncio + async def test_delete_async_returns_none(self) -> None: + # GIVEN an Evaluation with an ID + evaluation = Evaluation(id=EVALUATION_ID) + + # WHEN I call delete_async + with patch( + "synapseclient.api.evaluation_services.delete_evaluation", + new_callable=AsyncMock, + ): + result = await evaluation.delete_async(synapse_client=self.syn) + + # THEN the result should be None + assert result is None diff --git a/tests/unit/synapseclient/models/async/unit_test_link_async.py b/tests/unit/synapseclient/models/async/unit_test_link_async.py new file mode 100644 index 000000000..9e40a6450 --- /dev/null +++ b/tests/unit/synapseclient/models/async/unit_test_link_async.py @@ -0,0 +1,998 @@ +"""Unit tests for Link.""" + +from typing import Any, Dict +from unittest.mock import AsyncMock, patch + +import pytest + +from synapseclient import Synapse +from synapseclient.core.constants.concrete_types import LINK_ENTITY +from synapseclient.models import Folder, Link, Project + +LINK_NAME = "my_test_link" +DESCRIPTION = "A test link description" +LINK_ID = "syn123" +PARENT_ID = "syn456" +TARGET_ID = "syn789" +TARGET_VERSION_NUMBER = 3 +LINKS_TO_CLASS_NAME = "org.sagebionetworks.repo.model.FileEntity" +ETAG = "some_etag" +CREATED_ON = "2023-01-01T00:00:00Z" +MODIFIED_ON = "2023-01-02T00:00:00Z" +CREATED_BY = "user1" +MODIFIED_BY = "user2" + + +class TestLink: + """Unit tests for Link.""" + + @pytest.fixture(autouse=True, scope="function") + def init_syn(self, syn: Synapse) -> None: + self.syn = syn + + def get_example_rest_api_response(self) -> Dict[str, Any]: + """Return a mock REST API response for a Link entity.""" + return { + "name": LINK_NAME, + "description": DESCRIPTION, + "id": LINK_ID, + "etag": ETAG, + "createdOn": CREATED_ON, + "modifiedOn": MODIFIED_ON, + "createdBy": CREATED_BY, + "modifiedBy": MODIFIED_BY, + "parentId": PARENT_ID, + "concreteType": LINK_ENTITY, + "linksTo": { + "targetId": TARGET_ID, + "targetVersionNumber": TARGET_VERSION_NUMBER, + }, + "linksToClassName": LINKS_TO_CLASS_NAME, + "annotations": {}, + } + + def get_example_rest_api_response_no_version(self) -> Dict[str, Any]: + """Return a mock REST API response for a Link entity without target version.""" + response = self.get_example_rest_api_response() + response["linksTo"] = {"targetId": TARGET_ID} + return response + + # ------------------------------------------------------------------------- + # fill_from_dict tests + # ------------------------------------------------------------------------- + def test_fill_from_dict(self) -> None: + # GIVEN a blank Link + link = Link() + + # WHEN we fill it from a dictionary with all fields + result = link.fill_from_dict( + synapse_entity=self.get_example_rest_api_response() + ) + + # THEN the Link should have all fields filled + assert result.name == LINK_NAME + assert result.description == DESCRIPTION + assert result.id == LINK_ID + assert result.etag == ETAG + assert result.created_on == CREATED_ON + assert result.modified_on == MODIFIED_ON + assert result.created_by == CREATED_BY + assert result.modified_by == MODIFIED_BY + assert result.parent_id == PARENT_ID + assert result.target_id == TARGET_ID + assert result.target_version_number == TARGET_VERSION_NUMBER + assert result.links_to_class_name == LINKS_TO_CLASS_NAME + assert result.annotations == {} + + def test_fill_from_dict_with_no_links_to(self) -> None: + # GIVEN a blank Link + link = Link() + + # AND a response without a linksTo field + response = self.get_example_rest_api_response() + response.pop("linksTo") + + # WHEN we fill it from that response + result = link.fill_from_dict(synapse_entity=response) + + # THEN target_id and target_version_number should be None + assert result.target_id is None + assert result.target_version_number is None + + def test_fill_from_dict_without_target_version_number(self) -> None: + # GIVEN a blank Link + link = Link() + + # WHEN we fill it from a response that has linksTo without targetVersionNumber + result = link.fill_from_dict( + synapse_entity=self.get_example_rest_api_response_no_version() + ) + + # THEN target_id should be set but target_version_number should be None + assert result.target_id == TARGET_ID + assert result.target_version_number is None + + def test_fill_from_dict_without_annotations(self) -> None: + # GIVEN a blank Link + link = Link() + + # WHEN we fill it with set_annotations=False + result = link.fill_from_dict( + synapse_entity=self.get_example_rest_api_response(), + set_annotations=False, + ) + + # THEN annotations should remain the default (empty dict from dataclass) + assert result.annotations == {} + + # ------------------------------------------------------------------------- + # to_synapse_request tests + # ------------------------------------------------------------------------- + def test_to_synapse_request(self) -> None: + # GIVEN a Link with all fields + link = Link( + name=LINK_NAME, + description=DESCRIPTION, + id=LINK_ID, + etag=ETAG, + created_on=CREATED_ON, + modified_on=MODIFIED_ON, + created_by=CREATED_BY, + modified_by=MODIFIED_BY, + parent_id=PARENT_ID, + target_id=TARGET_ID, + target_version_number=TARGET_VERSION_NUMBER, + links_to_class_name=LINKS_TO_CLASS_NAME, + ) + + # WHEN we convert it to a Synapse request + request = link.to_synapse_request() + + # THEN the request should contain all expected fields + assert request["name"] == LINK_NAME + assert request["description"] == DESCRIPTION + assert request["id"] == LINK_ID + assert request["etag"] == ETAG + assert request["createdOn"] == CREATED_ON + assert request["modifiedOn"] == MODIFIED_ON + assert request["createdBy"] == CREATED_BY + assert request["modifiedBy"] == MODIFIED_BY + assert request["parentId"] == PARENT_ID + assert request["concreteType"] == LINK_ENTITY + assert request["linksTo"]["targetId"] == TARGET_ID + assert request["linksTo"]["targetVersionNumber"] == TARGET_VERSION_NUMBER + assert request["linksToClassName"] == LINKS_TO_CLASS_NAME + + def test_to_synapse_request_without_target(self) -> None: + # GIVEN a Link without a target_id + link = Link( + name=LINK_NAME, + parent_id=PARENT_ID, + ) + + # WHEN we convert it to a Synapse request + request = link.to_synapse_request() + + # THEN linksTo should not be in the request (None keys are removed) + assert "linksTo" not in request + + def test_to_synapse_request_without_target_version(self) -> None: + # GIVEN a Link with target_id but no target_version_number + link = Link( + name=LINK_NAME, + parent_id=PARENT_ID, + target_id=TARGET_ID, + ) + + # WHEN we convert it to a Synapse request + request = link.to_synapse_request() + + # THEN linksTo should have targetId but no targetVersionNumber + assert request["linksTo"]["targetId"] == TARGET_ID + assert "targetVersionNumber" not in request["linksTo"] + + def test_to_synapse_request_removes_none_keys(self) -> None: + # GIVEN a Link with only minimal fields + link = Link( + name=LINK_NAME, + target_id=TARGET_ID, + ) + + # WHEN we convert it to a Synapse request + request = link.to_synapse_request() + + # THEN None values should be removed + assert "id" not in request + assert "etag" not in request + assert "description" not in request + assert "createdOn" not in request + assert "modifiedOn" not in request + assert "createdBy" not in request + assert "modifiedBy" not in request + assert "parentId" not in request + assert "linksToClassName" not in request + + # AND the present fields should be there + assert request["name"] == LINK_NAME + assert request["concreteType"] == LINK_ENTITY + assert request["linksTo"]["targetId"] == TARGET_ID + + # ------------------------------------------------------------------------- + # get_async tests + # ------------------------------------------------------------------------- + async def test_get_by_id_follow_link_true(self) -> None: + # GIVEN a Link with an id + link = Link(id=LINK_ID) + + # WHEN we call get_async with follow_link=True (default) + with patch( + "synapseclient.models.link.get_id", + new_callable=AsyncMock, + return_value=LINK_ID, + ) as mocked_get_id, patch( + "synapseclient.models.link.get_from_entity_factory", + new_callable=AsyncMock, + ) as mocked_get_entity_factory, patch( + "synapseclient.operations.factory_operations.get_async", + new_callable=AsyncMock, + return_value="followed_entity", + ) as mocked_factory_get_async: + # Set up get_from_entity_factory to populate the link + async def fill_link(synapse_id_or_path, entity_to_update, synapse_client): + entity_to_update.fill_from_dict(self.get_example_rest_api_response()) + + mocked_get_entity_factory.side_effect = fill_link + + result = await link.get_async(synapse_client=self.syn) + + # THEN get_id should have been called + mocked_get_id.assert_called_once_with(entity=link, synapse_client=self.syn) + + # AND get_from_entity_factory should have been called + mocked_get_entity_factory.assert_called_once_with( + synapse_id_or_path=LINK_ID, + entity_to_update=link, + synapse_client=self.syn, + ) + + # AND factory_get_async should have been called to follow the link + mocked_factory_get_async.assert_called_once_with( + synapse_id=TARGET_ID, + version_number=TARGET_VERSION_NUMBER, + file_options=None, + synapse_client=self.syn, + ) + + # AND the result should be the followed entity + assert result == "followed_entity" + + async def test_get_by_id_follow_link_false(self) -> None: + # GIVEN a Link with an id + link = Link(id=LINK_ID) + + # WHEN we call get_async with follow_link=False + with patch( + "synapseclient.models.link.get_id", + new_callable=AsyncMock, + return_value=LINK_ID, + ) as mocked_get_id, patch( + "synapseclient.models.link.get_from_entity_factory", + new_callable=AsyncMock, + ) as mocked_get_entity_factory: + + async def fill_link(synapse_id_or_path, entity_to_update, synapse_client): + entity_to_update.fill_from_dict(self.get_example_rest_api_response()) + + mocked_get_entity_factory.side_effect = fill_link + + result = await link.get_async(follow_link=False, synapse_client=self.syn) + + # THEN get_id should have been called + mocked_get_id.assert_called_once_with(entity=link, synapse_client=self.syn) + + # AND get_from_entity_factory should have been called + mocked_get_entity_factory.assert_called_once_with( + synapse_id_or_path=LINK_ID, + entity_to_update=link, + synapse_client=self.syn, + ) + + # AND the result should be the Link itself + assert result is link + assert result.id == LINK_ID + assert result.name == LINK_NAME + assert result.target_id == TARGET_ID + assert result.target_version_number == TARGET_VERSION_NUMBER + + async def test_get_by_name_and_parent_id(self) -> None: + # GIVEN a Link with a name and parent_id but no id + link = Link(name=LINK_NAME, parent_id=PARENT_ID) + + # WHEN we call get_async + with patch( + "synapseclient.models.link.get_id", + new_callable=AsyncMock, + return_value=LINK_ID, + ) as mocked_get_id, patch( + "synapseclient.models.link.get_from_entity_factory", + new_callable=AsyncMock, + ) as mocked_get_entity_factory: + + async def fill_link(synapse_id_or_path, entity_to_update, synapse_client): + entity_to_update.fill_from_dict(self.get_example_rest_api_response()) + + mocked_get_entity_factory.side_effect = fill_link + + result = await link.get_async(follow_link=False, synapse_client=self.syn) + + # THEN get_id should have been called + mocked_get_id.assert_called_once_with(entity=link, synapse_client=self.syn) + + # AND the result should be the Link + assert result is link + assert result.id == LINK_ID + + async def test_get_by_name_and_parent_from_argument(self) -> None: + # GIVEN a Link with a name but no parent_id + link = Link(name=LINK_NAME) + + # AND a parent folder passed as argument + parent = Folder(id=PARENT_ID) + + # WHEN we call get_async with the parent + with patch( + "synapseclient.models.link.get_id", + new_callable=AsyncMock, + return_value=LINK_ID, + ), patch( + "synapseclient.models.link.get_from_entity_factory", + new_callable=AsyncMock, + ) as mocked_get_entity_factory: + + async def fill_link(synapse_id_or_path, entity_to_update, synapse_client): + entity_to_update.fill_from_dict(self.get_example_rest_api_response()) + + mocked_get_entity_factory.side_effect = fill_link + + result = await link.get_async( + parent=parent, follow_link=False, synapse_client=self.syn + ) + + # THEN the link's parent_id should be set from the parent argument + assert result.parent_id == PARENT_ID + + async def test_get_by_name_and_parent_project(self) -> None: + # GIVEN a Link with a name but no parent_id + link = Link(name=LINK_NAME) + + # AND a parent project passed as argument + parent = Project(id=PARENT_ID) + + # WHEN we call get_async with the parent + with patch( + "synapseclient.models.link.get_id", + new_callable=AsyncMock, + return_value=LINK_ID, + ), patch( + "synapseclient.models.link.get_from_entity_factory", + new_callable=AsyncMock, + ) as mocked_get_entity_factory: + + async def fill_link(synapse_id_or_path, entity_to_update, synapse_client): + entity_to_update.fill_from_dict(self.get_example_rest_api_response()) + + mocked_get_entity_factory.side_effect = fill_link + + result = await link.get_async( + parent=parent, follow_link=False, synapse_client=self.syn + ) + + # THEN the link's parent_id should be set from the parent argument + assert result.parent_id == PARENT_ID + + async def test_get_raises_when_no_id_and_no_name(self) -> None: + # GIVEN a Link with no id and no name + link = Link() + + # WHEN we call get_async + # THEN it should raise a ValueError + with pytest.raises( + ValueError, + match="The link must have an id or a " + "\\(name and \\(`parent_id` or parent with an id\\)\\) set.", + ): + await link.get_async(synapse_client=self.syn) + + async def test_get_raises_when_name_but_no_parent(self) -> None: + # GIVEN a Link with a name but no parent_id and no parent argument + link = Link(name=LINK_NAME) + + # WHEN we call get_async without a parent + # THEN it should raise a ValueError + with pytest.raises( + ValueError, + match="The link must have an id or a " + "\\(name and \\(`parent_id` or parent with an id\\)\\) set.", + ): + await link.get_async(synapse_client=self.syn) + + async def test_get_follow_link_with_file_options(self) -> None: + # GIVEN a Link with an id + link = Link(id=LINK_ID) + + # AND file options + mock_file_options = object() + + # WHEN we call get_async with follow_link=True and file_options + with patch( + "synapseclient.models.link.get_id", + new_callable=AsyncMock, + return_value=LINK_ID, + ), patch( + "synapseclient.models.link.get_from_entity_factory", + new_callable=AsyncMock, + ) as mocked_get_entity_factory, patch( + "synapseclient.operations.factory_operations.get_async", + new_callable=AsyncMock, + return_value="followed_file_entity", + ) as mocked_factory_get_async: + + async def fill_link(synapse_id_or_path, entity_to_update, synapse_client): + entity_to_update.fill_from_dict(self.get_example_rest_api_response()) + + mocked_get_entity_factory.side_effect = fill_link + + result = await link.get_async( + follow_link=True, + file_options=mock_file_options, + synapse_client=self.syn, + ) + + # THEN factory_get_async should be called with the file_options + mocked_factory_get_async.assert_called_once_with( + synapse_id=TARGET_ID, + version_number=TARGET_VERSION_NUMBER, + file_options=mock_file_options, + synapse_client=self.syn, + ) + + # AND the result should be the followed entity + assert result == "followed_file_entity" + + async def test_get_sets_last_persistent_instance(self) -> None: + # GIVEN a Link with an id + link = Link(id=LINK_ID) + + # WHEN we call get_async with follow_link=False + with patch( + "synapseclient.models.link.get_id", + new_callable=AsyncMock, + return_value=LINK_ID, + ), patch( + "synapseclient.models.link.get_from_entity_factory", + new_callable=AsyncMock, + ) as mocked_get_entity_factory: + + async def fill_link(synapse_id_or_path, entity_to_update, synapse_client): + entity_to_update.fill_from_dict(self.get_example_rest_api_response()) + + mocked_get_entity_factory.side_effect = fill_link + + result = await link.get_async(follow_link=False, synapse_client=self.syn) + + # THEN _last_persistent_instance should be set + assert result._last_persistent_instance is not None + assert result._last_persistent_instance.id == LINK_ID + assert result._last_persistent_instance.name == LINK_NAME + + # ------------------------------------------------------------------------- + # store_async tests + # ------------------------------------------------------------------------- + async def test_store_new_link(self) -> None: + # GIVEN a new Link with name, parent_id, and target_id + link = Link( + name=LINK_NAME, + parent_id=PARENT_ID, + target_id=TARGET_ID, + target_version_number=TARGET_VERSION_NUMBER, + ) + + # WHEN we call store_async + with patch( + "synapseclient.models.link.get_id", + new_callable=AsyncMock, + return_value=None, + ), patch( + "synapseclient.models.link.store_entity", + new_callable=AsyncMock, + return_value=self.get_example_rest_api_response(), + ) as mocked_store_entity, patch( + "synapseclient.models.link.store_entity_components", + new_callable=AsyncMock, + return_value=False, + ) as mocked_store_components: + result = await link.store_async(synapse_client=self.syn) + + # THEN store_entity should have been called + mocked_store_entity.assert_called_once() + call_kwargs = mocked_store_entity.call_args.kwargs + assert call_kwargs["resource"] is link + assert call_kwargs["synapse_client"] is self.syn + request = call_kwargs["entity"] + assert request["name"] == LINK_NAME + assert request["parentId"] == PARENT_ID + assert request["concreteType"] == LINK_ENTITY + assert request["linksTo"]["targetId"] == TARGET_ID + assert request["linksTo"]["targetVersionNumber"] == TARGET_VERSION_NUMBER + + # AND store_entity_components should have been called + mocked_store_components.assert_called_once_with( + root_resource=link, synapse_client=self.syn + ) + + # AND the link should be filled from the response + assert result.id == LINK_ID + assert result.name == LINK_NAME + assert result.etag == ETAG + assert result.parent_id == PARENT_ID + assert result.target_id == TARGET_ID + assert result.target_version_number == TARGET_VERSION_NUMBER + + # AND _last_persistent_instance should be set + assert result._last_persistent_instance is not None + + async def test_store_existing_link_with_id(self) -> None: + # GIVEN a Link with an id (existing entity) + link = Link( + id=LINK_ID, + name=LINK_NAME, + description="Updated description", + ) + + # WHEN we call store_async + with patch( + "synapseclient.models.link.get_id", + new_callable=AsyncMock, + return_value=LINK_ID, + ), patch( + "synapseclient.models.link.get_from_entity_factory", + new_callable=AsyncMock, + ) as mocked_get_entity_factory, patch( + "synapseclient.models.link.store_entity", + new_callable=AsyncMock, + return_value=self.get_example_rest_api_response(), + ) as mocked_store_entity, patch( + "synapseclient.models.link.store_entity_components", + new_callable=AsyncMock, + return_value=False, + ): + # Set up get_from_entity_factory to populate the link copy in + # _find_existing_entity + async def fill_link(synapse_id_or_path, entity_to_update, synapse_client): + entity_to_update.fill_from_dict(self.get_example_rest_api_response()) + + mocked_get_entity_factory.side_effect = fill_link + + result = await link.store_async(synapse_client=self.syn) + + # THEN store_entity should have been called (entity has changed) + mocked_store_entity.assert_called_once() + + # AND the result should reflect the stored state + assert result.id == LINK_ID + assert result.name == LINK_NAME + + async def test_store_with_parent_argument(self) -> None: + # GIVEN a Link without parent_id set + link = Link( + name=LINK_NAME, + target_id=TARGET_ID, + ) + + # AND a parent folder + parent = Folder(id=PARENT_ID) + + # WHEN we call store_async with the parent argument + with patch( + "synapseclient.models.link.get_id", + new_callable=AsyncMock, + return_value=None, + ), patch( + "synapseclient.models.link.store_entity", + new_callable=AsyncMock, + return_value=self.get_example_rest_api_response(), + ) as mocked_store_entity, patch( + "synapseclient.models.link.store_entity_components", + new_callable=AsyncMock, + return_value=False, + ): + result = await link.store_async(parent=parent, synapse_client=self.syn) + + # THEN the parent_id should be set from the parent argument + assert result.parent_id == PARENT_ID + + # AND store_entity should have been called with parent_id in the request + call_kwargs = mocked_store_entity.call_args.kwargs + request = call_kwargs["entity"] + assert request["parentId"] == PARENT_ID + + async def test_store_with_parent_project(self) -> None: + # GIVEN a Link without parent_id set + link = Link( + name=LINK_NAME, + target_id=TARGET_ID, + ) + + # AND a parent project + parent = Project(id=PARENT_ID) + + # WHEN we call store_async with the parent argument + with patch( + "synapseclient.models.link.get_id", + new_callable=AsyncMock, + return_value=None, + ), patch( + "synapseclient.models.link.store_entity", + new_callable=AsyncMock, + return_value=self.get_example_rest_api_response(), + ), patch( + "synapseclient.models.link.store_entity_components", + new_callable=AsyncMock, + return_value=False, + ): + result = await link.store_async(parent=parent, synapse_client=self.syn) + + # THEN the parent_id should be set from the parent argument + assert result.parent_id == PARENT_ID + + async def test_store_raises_when_no_name_and_no_id(self) -> None: + # GIVEN a Link with no name and no id + link = Link(parent_id=PARENT_ID, target_id=TARGET_ID) + + # WHEN we call store_async + # THEN it should raise a ValueError + with pytest.raises(ValueError, match="The link must have a name."): + await link.store_async(synapse_client=self.syn) + + async def test_store_raises_when_no_parent_id_and_no_id(self) -> None: + # GIVEN a Link with a name but no parent_id and no id + link = Link(name=LINK_NAME, target_id=TARGET_ID) + + # WHEN we call store_async + # THEN it should raise a ValueError + with pytest.raises(ValueError, match="The link must have a parent_id."): + await link.store_async(synapse_client=self.syn) + + async def test_store_raises_when_no_target_id_and_no_id(self) -> None: + # GIVEN a Link with a name and parent_id but no target_id and no id + link = Link(name=LINK_NAME, parent_id=PARENT_ID) + + # WHEN we call store_async + # THEN it should raise a ValueError + with pytest.raises(ValueError, match="The link must have a target_id."): + await link.store_async(synapse_client=self.syn) + + async def test_store_skips_validation_when_id_is_set(self) -> None: + # GIVEN a Link with only an id (no name, no parent_id, no target_id) + link = Link(id=LINK_ID) + + # WHEN we call store_async, it should NOT raise ValueError + with patch( + "synapseclient.models.link.get_id", + new_callable=AsyncMock, + return_value=LINK_ID, + ), patch( + "synapseclient.models.link.get_from_entity_factory", + new_callable=AsyncMock, + ) as mocked_get_entity_factory, patch( + "synapseclient.models.link.store_entity", + new_callable=AsyncMock, + return_value=self.get_example_rest_api_response(), + ), patch( + "synapseclient.models.link.store_entity_components", + new_callable=AsyncMock, + return_value=False, + ): + + async def fill_link(synapse_id_or_path, entity_to_update, synapse_client): + entity_to_update.fill_from_dict(self.get_example_rest_api_response()) + + mocked_get_entity_factory.side_effect = fill_link + + # THEN no ValueError is raised because id is set + result = await link.store_async(synapse_client=self.syn) + assert result.id == LINK_ID + + async def test_store_no_changes_skips_store_entity(self) -> None: + # GIVEN a Link that was previously retrieved from Synapse + link = Link( + id=LINK_ID, + name=LINK_NAME, + parent_id=PARENT_ID, + target_id=TARGET_ID, + ) + + # AND get_async has been called (which sets _last_persistent_instance) + with patch( + "synapseclient.models.link.get_id", + new_callable=AsyncMock, + return_value=LINK_ID, + ), patch( + "synapseclient.models.link.get_from_entity_factory", + new_callable=AsyncMock, + ) as mocked_get_entity_factory: + + async def fill_link(synapse_id_or_path, entity_to_update, synapse_client): + entity_to_update.fill_from_dict(self.get_example_rest_api_response()) + + mocked_get_entity_factory.side_effect = fill_link + await link.get_async(follow_link=False, synapse_client=self.syn) + + # WHEN we call store_async without making changes + with patch( + "synapseclient.models.link.store_entity", + new_callable=AsyncMock, + ) as mocked_store_entity, patch( + "synapseclient.models.link.store_entity_components", + new_callable=AsyncMock, + return_value=False, + ): + result = await link.store_async(synapse_client=self.syn) + + # THEN store_entity should NOT have been called + mocked_store_entity.assert_not_called() + + # AND the result should still be the link + assert result.id == LINK_ID + + async def test_store_with_changes_after_get(self) -> None: + # GIVEN a Link that was previously retrieved from Synapse + link = Link(id=LINK_ID) + + with patch( + "synapseclient.models.link.get_id", + new_callable=AsyncMock, + return_value=LINK_ID, + ), patch( + "synapseclient.models.link.get_from_entity_factory", + new_callable=AsyncMock, + ) as mocked_get_entity_factory: + + async def fill_link(synapse_id_or_path, entity_to_update, synapse_client): + entity_to_update.fill_from_dict(self.get_example_rest_api_response()) + + mocked_get_entity_factory.side_effect = fill_link + await link.get_async(follow_link=False, synapse_client=self.syn) + + # AND we make a change + link.description = "New description" + + # WHEN we call store_async + updated_response = self.get_example_rest_api_response() + updated_response["description"] = "New description" + + with patch( + "synapseclient.models.link.store_entity", + new_callable=AsyncMock, + return_value=updated_response, + ) as mocked_store_entity, patch( + "synapseclient.models.link.store_entity_components", + new_callable=AsyncMock, + return_value=False, + ): + result = await link.store_async(synapse_client=self.syn) + + # THEN store_entity SHOULD have been called because there are changes + mocked_store_entity.assert_called_once() + + # AND the result should reflect the updated data + assert result.id == LINK_ID + + async def test_store_re_reads_when_components_change(self) -> None: + # GIVEN a new Link + link = Link( + name=LINK_NAME, + parent_id=PARENT_ID, + target_id=TARGET_ID, + ) + + # WHEN we call store_async and store_entity_components returns True + with patch( + "synapseclient.models.link.get_id", + new_callable=AsyncMock, + return_value=None, + ), patch( + "synapseclient.models.link.store_entity", + new_callable=AsyncMock, + return_value=self.get_example_rest_api_response(), + ), patch( + "synapseclient.models.link.store_entity_components", + new_callable=AsyncMock, + return_value=True, + ), patch.object( + link, + "get_async", + new_callable=AsyncMock, + ) as mocked_get_async: + result = await link.store_async(synapse_client=self.syn) + + # THEN get_async should have been called for a re-read + mocked_get_async.assert_called_once_with( + synapse_client=self.syn, + ) + + async def test_store_does_not_re_read_when_no_component_changes(self) -> None: + # GIVEN a new Link + link = Link( + name=LINK_NAME, + parent_id=PARENT_ID, + target_id=TARGET_ID, + ) + + # WHEN we call store_async and store_entity_components returns False + with patch( + "synapseclient.models.link.get_id", + new_callable=AsyncMock, + return_value=None, + ), patch( + "synapseclient.models.link.store_entity", + new_callable=AsyncMock, + return_value=self.get_example_rest_api_response(), + ), patch( + "synapseclient.models.link.store_entity_components", + new_callable=AsyncMock, + return_value=False, + ), patch.object( + link, + "get_async", + new_callable=AsyncMock, + ) as mocked_get_async: + result = await link.store_async(synapse_client=self.syn) + + # THEN get_async should NOT have been called for a re-read + mocked_get_async.assert_not_called() + + # ------------------------------------------------------------------------- + # _find_existing_entity tests + # ------------------------------------------------------------------------- + async def test_find_existing_entity_when_entity_exists(self) -> None: + # GIVEN a Link with a name and parent_id (no _last_persistent_instance) + link = Link( + name=LINK_NAME, + parent_id=PARENT_ID, + target_id=TARGET_ID, + ) + + # WHEN we call _find_existing_entity and an entity is found + with patch( + "synapseclient.models.link.get_id", + new_callable=AsyncMock, + return_value=LINK_ID, + ), patch( + "synapseclient.models.link.get_from_entity_factory", + new_callable=AsyncMock, + ) as mocked_get_entity_factory: + + async def fill_link(synapse_id_or_path, entity_to_update, synapse_client): + entity_to_update.fill_from_dict(self.get_example_rest_api_response()) + + mocked_get_entity_factory.side_effect = fill_link + + result = await link._find_existing_entity(synapse_client=self.syn) + + # THEN the result should be a Link with the existing data + assert result is not None + assert result.id == LINK_ID + assert result.name == LINK_NAME + + async def test_find_existing_entity_when_no_entity_exists(self) -> None: + # GIVEN a Link with a name and parent_id + link = Link( + name=LINK_NAME, + parent_id=PARENT_ID, + target_id=TARGET_ID, + ) + + # WHEN we call _find_existing_entity and no entity is found + with patch( + "synapseclient.models.link.get_id", + new_callable=AsyncMock, + return_value=None, + ): + result = await link._find_existing_entity(synapse_client=self.syn) + + # THEN the result should be None + assert result is None + + async def test_find_existing_entity_skipped_when_last_persistent_instance_set( + self, + ) -> None: + # GIVEN a Link that already has _last_persistent_instance + link = Link( + id=LINK_ID, + name=LINK_NAME, + parent_id=PARENT_ID, + target_id=TARGET_ID, + ) + link._set_last_persistent_instance() + + # WHEN we call _find_existing_entity + with patch( + "synapseclient.models.link.get_id", + new_callable=AsyncMock, + ) as mocked_get_id: + result = await link._find_existing_entity(synapse_client=self.syn) + + # THEN get_id should NOT have been called + mocked_get_id.assert_not_called() + + # AND the result should be None + assert result is None + + # ------------------------------------------------------------------------- + # has_changed property tests + # ------------------------------------------------------------------------- + def test_has_changed_when_no_last_persistent_instance(self) -> None: + # GIVEN a Link with no _last_persistent_instance + link = Link(name=LINK_NAME) + + # WHEN we check has_changed + # THEN it should be True + assert link.has_changed is True + + def test_has_changed_when_no_changes(self) -> None: + # GIVEN a Link with _last_persistent_instance set + link = Link( + id=LINK_ID, + name=LINK_NAME, + parent_id=PARENT_ID, + target_id=TARGET_ID, + ) + link._set_last_persistent_instance() + + # WHEN we check has_changed without making changes + # THEN it should be False + assert link.has_changed is False + + def test_has_changed_after_modification(self) -> None: + # GIVEN a Link with _last_persistent_instance set + link = Link( + id=LINK_ID, + name=LINK_NAME, + parent_id=PARENT_ID, + target_id=TARGET_ID, + ) + link._set_last_persistent_instance() + + # WHEN we modify the link + link.description = "Something new" + + # THEN has_changed should be True + assert link.has_changed is True + + # ------------------------------------------------------------------------- + # _set_last_persistent_instance tests + # ------------------------------------------------------------------------- + def test_set_last_persistent_instance(self) -> None: + # GIVEN a Link with data + link = Link( + id=LINK_ID, + name=LINK_NAME, + parent_id=PARENT_ID, + target_id=TARGET_ID, + annotations={"key": ["value"]}, + ) + + # WHEN we set the last persistent instance + link._set_last_persistent_instance() + + # THEN it should be a copy of the current state + assert link._last_persistent_instance is not None + assert link._last_persistent_instance.id == LINK_ID + assert link._last_persistent_instance.name == LINK_NAME + assert link._last_persistent_instance.parent_id == PARENT_ID + assert link._last_persistent_instance.target_id == TARGET_ID + + # AND annotations should be a deep copy + assert link._last_persistent_instance.annotations == {"key": ["value"]} + assert link._last_persistent_instance.annotations is not link.annotations diff --git a/tests/unit/synapseclient/models/async/unit_test_recordset_async.py b/tests/unit/synapseclient/models/async/unit_test_recordset_async.py new file mode 100644 index 000000000..0a24d4eae --- /dev/null +++ b/tests/unit/synapseclient/models/async/unit_test_recordset_async.py @@ -0,0 +1,840 @@ +"""Unit tests for the RecordSet model.""" + +import os +from contextlib import asynccontextmanager +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from synapseclient import Synapse +from synapseclient.core.constants import concrete_types +from synapseclient.models import Activity, RecordSet +from synapseclient.models.recordset import ValidationSummary + +SYN_123 = "syn123" +SYN_456 = "syn456" +RECORD_SET_NAME = "test_record_set.csv" +PATH = "/tmp/test_record_set.csv" +DOWNLOAD_DIR = "/tmp/download_dir" +DESCRIPTION = "A test record set" +ETAG = "etag-abc-123" +CREATED_ON = "2024-01-01T00:00:00.000Z" +MODIFIED_ON = "2024-01-02T00:00:00.000Z" +CREATED_BY = "111111" +MODIFIED_BY = "222222" +PARENT_ID = "syn999" +VERSION_LABEL = "v1" +VERSION_COMMENT = "Initial version" +DATA_FILE_HANDLE_ID = "888" +VALIDATION_FILE_HANDLE_ID = "999" +VERSION_NUMBER = 1 +CONTENT_MD5 = "abc123md5" + + +def _get_record_set_entity_response(**overrides): + """Return a mock RecordSet entity response from the REST API.""" + response = { + "id": SYN_123, + "name": RECORD_SET_NAME, + "description": DESCRIPTION, + "etag": ETAG, + "createdOn": CREATED_ON, + "modifiedOn": MODIFIED_ON, + "createdBy": CREATED_BY, + "modifiedBy": MODIFIED_BY, + "parentId": PARENT_ID, + "versionNumber": VERSION_NUMBER, + "versionLabel": VERSION_LABEL, + "versionComment": VERSION_COMMENT, + "isLatestVersion": True, + "dataFileHandleId": DATA_FILE_HANDLE_ID, + "concreteType": concrete_types.RECORD_SET_ENTITY, + "validationFileHandleId": VALIDATION_FILE_HANDLE_ID, + } + response.update(overrides) + return response + + +class TestRecordSet: + """Unit tests for the RecordSet model.""" + + @pytest.fixture(autouse=True, scope="function") + def init_syn(self, syn: Synapse) -> None: + self.syn = syn + + def test_fill_from_dict(self) -> None: + # GIVEN a RecordSet entity response + entity = _get_record_set_entity_response() + + # WHEN I fill a RecordSet from the entity response + record_set = RecordSet() + record_set.fill_from_dict(entity) + + # THEN all fields should be populated correctly + assert record_set.id == SYN_123 + assert record_set.name == RECORD_SET_NAME + assert record_set.description == DESCRIPTION + assert record_set.etag == ETAG + assert record_set.created_on == CREATED_ON + assert record_set.modified_on == MODIFIED_ON + assert record_set.created_by == CREATED_BY + assert record_set.modified_by == MODIFIED_BY + assert record_set.parent_id == PARENT_ID + assert record_set.version_number == VERSION_NUMBER + assert record_set.version_label == VERSION_LABEL + assert record_set.version_comment == VERSION_COMMENT + assert record_set.is_latest_version is True + assert record_set.data_file_handle_id == DATA_FILE_HANDLE_ID + assert record_set.validation_file_handle_id == VALIDATION_FILE_HANDLE_ID + + def test_fill_from_dict_with_validation_summary(self) -> None: + # GIVEN a RecordSet entity response with validation summary + entity = _get_record_set_entity_response( + validationSummary={ + "containerId": SYN_123, + "totalNumberOfChildren": 10, + "numberOfValidChildren": 7, + "numberOfInvalidChildren": 2, + "numberOfUnknownChildren": 1, + "generatedOn": "2024-05-01T00:00:00.000Z", + } + ) + + # WHEN I fill a RecordSet from the entity response + record_set = RecordSet() + record_set.fill_from_dict(entity) + + # THEN the validation_summary should be populated + assert record_set.validation_summary is not None + assert record_set.validation_summary.container_id == SYN_123 + assert record_set.validation_summary.total_number_of_children == 10 + assert record_set.validation_summary.number_of_valid_children == 7 + assert record_set.validation_summary.number_of_invalid_children == 2 + assert record_set.validation_summary.number_of_unknown_children == 1 + + def test_fill_from_dict_with_upsert_keys(self) -> None: + # GIVEN a RecordSet entity response with upsert keys + entity = _get_record_set_entity_response(upsertKey=["col_a", "col_b"]) + + # WHEN I fill a RecordSet from the entity response + record_set = RecordSet() + record_set.fill_from_dict(entity) + + # THEN the upsert_keys should be populated + assert record_set.upsert_keys == ["col_a", "col_b"] + + def test_to_synapse_request(self) -> None: + # GIVEN a RecordSet with fields set + record_set = RecordSet( + id=SYN_123, + name=RECORD_SET_NAME, + description=DESCRIPTION, + parent_id=PARENT_ID, + etag=ETAG, + data_file_handle_id=DATA_FILE_HANDLE_ID, + version_label=VERSION_LABEL, + ) + + # WHEN I convert it to a Synapse request + request = record_set.to_synapse_request() + + # THEN the request should contain the correct values + assert request["concreteType"] == concrete_types.RECORD_SET_ENTITY + assert request["id"] == SYN_123 + assert request["name"] == RECORD_SET_NAME + assert request["description"] == DESCRIPTION + assert request["parentId"] == PARENT_ID + assert request["etag"] == ETAG + assert request["dataFileHandleId"] == DATA_FILE_HANDLE_ID + assert request["versionLabel"] == VERSION_LABEL + + def test_to_synapse_request_with_validation_summary(self) -> None: + # GIVEN a RecordSet with a validation_summary + record_set = RecordSet( + id=SYN_123, + name=RECORD_SET_NAME, + parent_id=PARENT_ID, + validation_summary=ValidationSummary( + container_id=SYN_123, + total_number_of_children=5, + number_of_valid_children=4, + number_of_invalid_children=1, + ), + ) + + # WHEN I convert it to a request + request = record_set.to_synapse_request() + + # THEN the validation summary should be included + assert "validationSummary" in request + assert request["validationSummary"]["containerId"] == SYN_123 + assert request["validationSummary"]["totalNumberOfChildren"] == 5 + + def test_cannot_store_no_id_no_path_no_parent(self) -> None: + # GIVEN a RecordSet with no id, path, or parent + record_set = RecordSet(name=RECORD_SET_NAME) + + # WHEN I check _cannot_store + # THEN it should be True + assert record_set._cannot_store() is True + + def test_cannot_store_with_path_and_parent(self) -> None: + # GIVEN a RecordSet with a path and parent_id + record_set = RecordSet(path=PATH, parent_id=PARENT_ID) + + # WHEN I check _cannot_store + # THEN it should be False + assert record_set._cannot_store() is False + + def test_cannot_store_with_id_and_path(self) -> None: + # GIVEN a RecordSet with an id and path + record_set = RecordSet(id=SYN_123, path=PATH) + + # WHEN I check _cannot_store + # THEN it should be False + assert record_set._cannot_store() is False + + def test_cannot_store_with_id_and_data_file_handle_id(self) -> None: + # GIVEN a RecordSet with an id and data_file_handle_id + record_set = RecordSet(id=SYN_123, data_file_handle_id=DATA_FILE_HANDLE_ID) + + # WHEN I check _cannot_store + # THEN it should be False + assert record_set._cannot_store() is False + + def test_cannot_store_with_parent_and_data_file_handle_id(self) -> None: + # GIVEN a RecordSet with a parent_id and data_file_handle_id + record_set = RecordSet( + parent_id=PARENT_ID, data_file_handle_id=DATA_FILE_HANDLE_ID + ) + + # WHEN I check _cannot_store + # THEN it should be False + assert record_set._cannot_store() is False + + def test_has_changed_true_initially(self) -> None: + # GIVEN a new RecordSet + record_set = RecordSet(id=SYN_123, name=RECORD_SET_NAME) + + # WHEN I check has_changed before any persistent instance + # THEN it should be True + assert record_set.has_changed is True + + def test_has_changed_false_after_set(self) -> None: + # GIVEN a RecordSet with a persistent instance set + record_set = RecordSet(id=SYN_123, name=RECORD_SET_NAME) + record_set._set_last_persistent_instance() + + # WHEN I check has_changed without modification + # THEN it should be False + assert record_set.has_changed is False + + def test_has_changed_true_after_modification(self) -> None: + # GIVEN a RecordSet with a persistent instance set + record_set = RecordSet(id=SYN_123, name=RECORD_SET_NAME) + record_set._set_last_persistent_instance() + + # WHEN I modify the record set + record_set.description = "Changed description" + + # THEN has_changed should be True + assert record_set.has_changed is True + + def test_determine_fields_to_ignore_default(self) -> None: + # GIVEN a RecordSet with default settings + record_set = RecordSet() + + # WHEN I determine fields to ignore + result = record_set._determine_fields_to_ignore_in_merge() + + # THEN annotations should not be ignored (merge_existing_annotations=True) + assert "annotations" not in result + # AND activity should be ignored (associate_activity_to_new_version=False) + assert "activity" in result + + def test_determine_fields_to_ignore_no_merge_annotations(self) -> None: + # GIVEN a RecordSet with merge_existing_annotations=False + record_set = RecordSet(merge_existing_annotations=False) + + # WHEN I determine fields to ignore + result = record_set._determine_fields_to_ignore_in_merge() + + # THEN annotations should be in the ignore list + assert "annotations" in result + + def test_determine_fields_to_ignore_with_activity_association(self) -> None: + # GIVEN a RecordSet with associate_activity_to_new_version=True + record_set = RecordSet(associate_activity_to_new_version=True) + + # WHEN I determine fields to ignore + result = record_set._determine_fields_to_ignore_in_merge() + + # THEN activity should NOT be in the ignore list + assert "activity" not in result + + async def test_store_async_with_path_and_parent(self) -> None: + # GIVEN a new RecordSet with a path and parent_id + record_set = RecordSet( + path=PATH, + parent_id=PARENT_ID, + name=RECORD_SET_NAME, + description=DESCRIPTION, + ) + + entity_response = _get_record_set_entity_response() + + # Mock the parallel file transfer semaphore + mock_semaphore = MagicMock() + + @asynccontextmanager + async def mock_semaphore_ctx(*args, **kwargs): + yield mock_semaphore + + self.syn._get_parallel_file_transfer_semaphore = mock_semaphore_ctx + + # WHEN I call store_async + with patch( + "synapseclient.models.recordset.get_id", + new_callable=AsyncMock, + return_value=None, + ), patch( + "synapseclient.models.file._upload_file", + new_callable=AsyncMock, + ) as mock_upload, patch( + "synapseclient.models.recordset.store_entity", + new_callable=AsyncMock, + return_value=entity_response, + ) as mock_store_entity, patch( + "synapseclient.models.recordset.store_entity_components", + new_callable=AsyncMock, + return_value=False, + ), patch( + "os.path.expanduser", + return_value=PATH, + ): + result = await record_set.store_async(synapse_client=self.syn) + + # THEN the upload function should be called + mock_upload.assert_called_once() + + # AND the store_entity function should be called + mock_store_entity.assert_called_once() + + # AND the result should be populated from the response + assert result.id == SYN_123 + assert result.name == RECORD_SET_NAME + + async def test_store_async_with_data_file_handle_id(self) -> None: + # GIVEN a RecordSet with a data_file_handle_id and parent_id + record_set = RecordSet( + data_file_handle_id=DATA_FILE_HANDLE_ID, + parent_id=PARENT_ID, + name=RECORD_SET_NAME, + ) + + entity_response = _get_record_set_entity_response() + + # Mock cache.get to return None (no cached path) + self.syn.cache = MagicMock() + self.syn.cache.get.return_value = None + + # WHEN I call store_async + with patch( + "synapseclient.models.recordset.get_id", + new_callable=AsyncMock, + return_value=None, + ), patch( + "synapseclient.models.recordset.store_entity", + new_callable=AsyncMock, + return_value=entity_response, + ) as mock_store_entity, patch( + "synapseclient.models.recordset.store_entity_components", + new_callable=AsyncMock, + return_value=False, + ): + result = await record_set.store_async(synapse_client=self.syn) + + # THEN store_entity should be called (no file upload needed) + mock_store_entity.assert_called_once() + + # AND the result should be populated + assert result.id == SYN_123 + + async def test_store_async_with_parent_object(self) -> None: + # GIVEN a RecordSet with a path but parent passed as argument + record_set = RecordSet( + path=PATH, + name=RECORD_SET_NAME, + ) + + entity_response = _get_record_set_entity_response() + + # Create a mock parent + from synapseclient.models import Folder + + parent = Folder(id=PARENT_ID) + + # Mock semaphore + mock_semaphore = MagicMock() + + @asynccontextmanager + async def mock_semaphore_ctx(*args, **kwargs): + yield mock_semaphore + + self.syn._get_parallel_file_transfer_semaphore = mock_semaphore_ctx + + # WHEN I call store_async with a parent object + with patch( + "synapseclient.models.recordset.get_id", + new_callable=AsyncMock, + return_value=None, + ), patch( + "synapseclient.models.file._upload_file", + new_callable=AsyncMock, + ), patch( + "synapseclient.models.recordset.store_entity", + new_callable=AsyncMock, + return_value=entity_response, + ), patch( + "synapseclient.models.recordset.store_entity_components", + new_callable=AsyncMock, + return_value=False, + ), patch( + "os.path.expanduser", + return_value=PATH, + ): + result = await record_set.store_async( + parent=parent, synapse_client=self.syn + ) + + # THEN the parent_id should be set from the parent object + assert result.parent_id == PARENT_ID + + async def test_store_async_cannot_store_raises(self) -> None: + # GIVEN a RecordSet that cannot be stored (missing required info) + record_set = RecordSet(name=RECORD_SET_NAME) + + # WHEN I call store_async + # THEN it should raise ValueError + with pytest.raises(ValueError, match="must have"): + await record_set.store_async(synapse_client=self.syn) + + async def test_store_async_update_existing(self) -> None: + # GIVEN a RecordSet that has been retrieved from Synapse + record_set = RecordSet( + id=SYN_123, + path=PATH, + parent_id=PARENT_ID, + name=RECORD_SET_NAME, + description="Updated description", + create_or_update=True, + ) + + existing_entity_response = _get_record_set_entity_response() + updated_entity_response = _get_record_set_entity_response( + description="Updated description" + ) + + # Mock the semaphore + mock_semaphore = MagicMock() + + @asynccontextmanager + async def mock_semaphore_ctx(*args, **kwargs): + yield mock_semaphore + + self.syn._get_parallel_file_transfer_semaphore = mock_semaphore_ctx + + # WHEN I call store_async and an existing entity is found + with patch( + "synapseclient.models.recordset.get_id", + new_callable=AsyncMock, + return_value=SYN_123, + ), patch( + "synapseclient.api.entity_factory.get_entity_id_bundle2", + new_callable=AsyncMock, + return_value={"entity": existing_entity_response, "fileHandles": []}, + ), patch( + "synapseclient.models.file._upload_file", + new_callable=AsyncMock, + ), patch( + "synapseclient.models.recordset.store_entity", + new_callable=AsyncMock, + return_value=updated_entity_response, + ), patch( + "synapseclient.models.recordset.store_entity_components", + new_callable=AsyncMock, + return_value=False, + ), patch( + "os.path.expanduser", + return_value=PATH, + ): + result = await record_set.store_async(synapse_client=self.syn) + + # THEN the result should have the merged/updated fields + assert result.id == SYN_123 + + async def test_store_async_re_read_required(self) -> None: + # GIVEN a RecordSet that triggers a re-read after store + record_set = RecordSet( + data_file_handle_id=DATA_FILE_HANDLE_ID, + parent_id=PARENT_ID, + name=RECORD_SET_NAME, + ) + + entity_response = _get_record_set_entity_response() + + self.syn.cache = MagicMock() + self.syn.cache.get.return_value = None + + # WHEN I call store_async and store_entity_components returns True + with patch( + "synapseclient.models.recordset.get_id", + new_callable=AsyncMock, + return_value=None, + ), patch( + "synapseclient.models.recordset.store_entity", + new_callable=AsyncMock, + return_value=entity_response, + ), patch( + "synapseclient.models.recordset.store_entity_components", + new_callable=AsyncMock, + return_value=True, + ), patch.object( + RecordSet, + "get_async", + new_callable=AsyncMock, + return_value=record_set, + ) as mock_get: + result = await record_set.store_async(synapse_client=self.syn) + + # THEN get_async should be called again to re-read the entity + mock_get.assert_called_once() + + async def test_get_async_with_id(self) -> None: + # GIVEN a RecordSet with an id + record_set = RecordSet(id=SYN_123) + + entity_response = _get_record_set_entity_response() + + # WHEN I call get_async + with patch( + "synapseclient.models.recordset.get_from_entity_factory", + new_callable=AsyncMock, + ) as mock_factory: + # Mock the entity factory to fill the record set + async def side_effect(**kwargs): + entity_to_update = kwargs["entity_to_update"] + entity_to_update.fill_from_dict(entity_response) + + mock_factory.side_effect = side_effect + + self.syn.cache = MagicMock() + self.syn.cache.get.return_value = None + + result = await record_set.get_async(synapse_client=self.syn) + + # THEN the entity factory should be called + mock_factory.assert_called_once() + + # AND the result should be populated + assert result.id == SYN_123 + assert result.name == RECORD_SET_NAME + + async def test_get_async_with_path(self) -> None: + # GIVEN a RecordSet with a path (looking up by local file) + record_set = RecordSet(path=PATH) + + entity_response = _get_record_set_entity_response() + + # WHEN I call get_async + with patch( + "synapseclient.models.recordset.get_from_entity_factory", + new_callable=AsyncMock, + ) as mock_factory, patch( + "os.path.isfile", + return_value=False, + ): + + async def side_effect(**kwargs): + entity_to_update = kwargs["entity_to_update"] + entity_to_update.fill_from_dict(entity_response) + + mock_factory.side_effect = side_effect + + self.syn.cache = MagicMock() + self.syn.cache.get.return_value = None + + result = await record_set.get_async(synapse_client=self.syn) + + # THEN the entity factory should be called with the path + mock_factory.assert_called_once() + call_kwargs = mock_factory.call_args[1] + assert call_kwargs["synapse_id_or_path"] == PATH + + async def test_get_async_without_id_or_path_raises(self) -> None: + # GIVEN a RecordSet with no id or path + record_set = RecordSet() + + # WHEN I call get_async + # THEN it should raise ValueError + with pytest.raises(ValueError, match="must have an ID or path"): + await record_set.get_async(synapse_client=self.syn) + + async def test_get_async_include_activity(self) -> None: + # GIVEN a RecordSet with an id + record_set = RecordSet(id=SYN_123) + + entity_response = _get_record_set_entity_response() + + activity_response = Activity( + id="act123", + name="Test Activity", + ) + + # WHEN I call get_async with include_activity=True + with patch( + "synapseclient.models.recordset.get_from_entity_factory", + new_callable=AsyncMock, + ) as mock_factory, patch( + "synapseclient.models.Activity.from_parent_async", + new_callable=AsyncMock, + return_value=activity_response, + ) as mock_from_parent: + + async def side_effect(**kwargs): + entity_to_update = kwargs["entity_to_update"] + entity_to_update.fill_from_dict(entity_response) + + mock_factory.side_effect = side_effect + + self.syn.cache = MagicMock() + self.syn.cache.get.return_value = None + + result = await record_set.get_async( + include_activity=True, synapse_client=self.syn + ) + + # THEN the activity should be retrieved + mock_from_parent.assert_called_once() + + # AND the result should have the activity + assert result.activity is not None + assert result.activity.name == "Test Activity" + + async def test_get_async_uses_cache_path(self) -> None: + # GIVEN a RecordSet with an id and data_file_handle_id + record_set = RecordSet(id=SYN_123) + + entity_response = _get_record_set_entity_response() + cached_path = "/cached/path/to/file.csv" + + # WHEN I call get_async and cache has the file + with patch( + "synapseclient.models.recordset.get_from_entity_factory", + new_callable=AsyncMock, + ) as mock_factory: + + async def side_effect(**kwargs): + entity_to_update = kwargs["entity_to_update"] + entity_to_update.fill_from_dict(entity_response) + # Simulate the factory setting data_file_handle_id but no path + entity_to_update.path = None + + mock_factory.side_effect = side_effect + + self.syn.cache = MagicMock() + self.syn.cache.get.return_value = cached_path + + result = await record_set.get_async(synapse_client=self.syn) + + # THEN the cache should be checked + self.syn.cache.get.assert_called_once_with( + file_handle_id=DATA_FILE_HANDLE_ID + ) + + # AND the path should come from cache + assert result.path == cached_path + + async def test_delete_async_with_id(self) -> None: + # GIVEN a RecordSet with an id + record_set = RecordSet(id=SYN_123) + + # WHEN I call delete_async + with patch.object( + self.syn, + "delete", + return_value=None, + ) as mock_delete: + await record_set.delete_async(synapse_client=self.syn) + + # THEN the Synapse delete should be called with the id + mock_delete.assert_called_once_with(obj=SYN_123, version=None) + + async def test_delete_async_without_id_raises(self) -> None: + # GIVEN a RecordSet without an id + record_set = RecordSet() + + # WHEN I call delete_async + # THEN it should raise ValueError + with pytest.raises(ValueError, match="must have an ID to delete"): + await record_set.delete_async(synapse_client=self.syn) + + async def test_delete_async_version_only(self) -> None: + # GIVEN a RecordSet with an id and version_number + record_set = RecordSet(id=SYN_123, version_number=2) + + # WHEN I call delete_async with version_only=True + with patch.object( + self.syn, + "delete", + return_value=None, + ) as mock_delete: + await record_set.delete_async(version_only=True, synapse_client=self.syn) + + # THEN the Synapse delete should be called with the version + mock_delete.assert_called_once_with(obj=SYN_123, version=2) + + async def test_delete_async_version_only_no_version_raises(self) -> None: + # GIVEN a RecordSet with an id but no version_number + record_set = RecordSet(id=SYN_123) + + # WHEN I call delete_async with version_only=True + # THEN it should raise ValueError + with pytest.raises( + ValueError, match="must have a version number to delete a version" + ): + await record_set.delete_async(version_only=True, synapse_client=self.syn) + + async def test_get_detailed_validation_results_async_with_handle_id( + self, + ) -> None: + # GIVEN a RecordSet with a validation_file_handle_id + record_set = RecordSet( + id=SYN_123, validation_file_handle_id=VALIDATION_FILE_HANDLE_ID + ) + + mock_df = MagicMock() + mock_df.__len__ = MagicMock(return_value=10) + cached_path = "/cached/validation_results.csv" + + self.syn.cache = MagicMock() + self.syn.cache.get.return_value = cached_path + self.syn.cache.get_cache_dir.return_value = "/syn_cache_dir" + + # WHEN I call get_detailed_validation_results_async + with patch( + "synapseclient.models.recordset.test_import_pandas", + ), patch( + "synapseclient.models.recordset.download_by_file_handle", + new_callable=AsyncMock, + return_value="/cached/validation_results.csv", + ) as mock_download, patch( + "pandas.read_csv", + return_value=mock_df, + ) as mock_read_csv: + result = await record_set.get_detailed_validation_results_async( + synapse_client=self.syn + ) + + # THEN the download function should be called + mock_download.assert_called_once() + + # AND pandas should read the CSV + mock_read_csv.assert_called_once_with("/cached/validation_results.csv") + + # AND the result should be the DataFrame + assert result == mock_df + + async def test_get_detailed_validation_results_async_no_handle_id(self) -> None: + # GIVEN a RecordSet without a validation_file_handle_id + record_set = RecordSet(id=SYN_123) + + # WHEN I call get_detailed_validation_results_async + with patch( + "synapseclient.models.recordset.test_import_pandas", + ), patch( + "pandas.read_csv", + ): + result = await record_set.get_detailed_validation_results_async( + synapse_client=self.syn + ) + + # THEN the result should be None + assert result is None + + async def test_get_detailed_validation_results_async_download_location( + self, + ) -> None: + # GIVEN a RecordSet with a validation_file_handle_id + record_set = RecordSet( + id=SYN_123, validation_file_handle_id=VALIDATION_FILE_HANDLE_ID + ) + + mock_df = MagicMock() + download_location = "/custom/download/dir" + + self.syn.cache = MagicMock() + self.syn.cache.get.return_value = None + self.syn.cache.get_cache_dir.return_value = "/syn_cache_dir" + + # WHEN I call get_detailed_validation_results_async with a download_location + with patch( + "synapseclient.models.recordset.test_import_pandas", + ), patch( + "synapseclient.models.recordset.ensure_download_location_is_directory", + return_value=download_location, + ), patch( + "synapseclient.models.recordset.download_by_file_handle", + new_callable=AsyncMock, + return_value=f"{download_location}/SYNAPSE_RECORDSET_VALIDATION_{VALIDATION_FILE_HANDLE_ID}.csv", + ) as mock_download, patch( + "pandas.read_csv", + return_value=mock_df, + ): + result = await record_set.get_detailed_validation_results_async( + download_location=download_location, synapse_client=self.syn + ) + + # THEN the download should use the custom location + call_kwargs = mock_download.call_args[1] + assert download_location in call_kwargs["destination"] + + # AND the result should be the DataFrame + assert result == mock_df + + +class TestValidationSummary: + """Tests for the ValidationSummary dataclass.""" + + def test_creation(self) -> None: + # GIVEN validation summary data + # WHEN I create a ValidationSummary + summary = ValidationSummary( + container_id=SYN_123, + total_number_of_children=20, + number_of_valid_children=15, + number_of_invalid_children=3, + number_of_unknown_children=2, + generated_on="2024-05-01T00:00:00.000Z", + ) + + # THEN all fields should be set + assert summary.container_id == SYN_123 + assert summary.total_number_of_children == 20 + assert summary.number_of_valid_children == 15 + assert summary.number_of_invalid_children == 3 + assert summary.number_of_unknown_children == 2 + assert summary.generated_on == "2024-05-01T00:00:00.000Z" + + def test_defaults_to_none(self) -> None: + # GIVEN no arguments + # WHEN I create a ValidationSummary + summary = ValidationSummary() + + # THEN all fields should be None + assert summary.container_id is None + assert summary.total_number_of_children is None + assert summary.number_of_valid_children is None + assert summary.number_of_invalid_children is None + assert summary.number_of_unknown_children is None + assert summary.generated_on is None diff --git a/tests/unit/synapseclient/models/async/unit_test_schema_organization_async.py b/tests/unit/synapseclient/models/async/unit_test_schema_organization_async.py new file mode 100644 index 000000000..494e064ba --- /dev/null +++ b/tests/unit/synapseclient/models/async/unit_test_schema_organization_async.py @@ -0,0 +1,979 @@ +"""Unit tests for the SchemaOrganization and JSONSchema models.""" + +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from synapseclient import Synapse +from synapseclient.models.mixins.json_schema import JSONSchemaVersionInfo +from synapseclient.models.schema_organization import ( + CreateSchemaRequest, + JSONSchema, + SchemaOrganization, + _check_name, +) + +ORG_NAME = "mytest.organization" +ORG_ID = "1075" +CREATED_ON = "2024-01-01T00:00:00.000Z" +CREATED_BY = "111111" +SCHEMA_NAME = "mytest.schemaname" +SCHEMA_ID = "5001" +SCHEMA_URI = f"{ORG_NAME}-{SCHEMA_NAME}" +ORG_ID_INT = 1075 +VERSION = "1.0.0" +VERSION_ID = "9001" +JSON_SHA_HEX = "abc123sha256" +PRINCIPAL_ID_1 = 100 +PRINCIPAL_ID_2 = 200 +REPO_ENDPOINT = "https://repo-prod.prod.sagebase.org/repo/v1" + +SCHEMA_BODY = { + "properties": { + "Component": { + "description": "TBD", + "not": {"type": "null"}, + "title": "Component", + } + } +} + + +def _get_organization_response(**overrides): + """Return a mock organization API response.""" + response = { + "name": ORG_NAME, + "id": ORG_ID, + "createdOn": CREATED_ON, + "createdBy": CREATED_BY, + } + response.update(overrides) + return response + + +def _get_json_schema_list_response(**overrides): + """Return a mock JSON schema list item response.""" + response = { + "organizationId": ORG_ID_INT, + "organizationName": ORG_NAME, + "schemaId": SCHEMA_ID, + "schemaName": SCHEMA_NAME, + "createdOn": CREATED_ON, + "createdBy": CREATED_BY, + } + response.update(overrides) + return response + + +def _get_schema_version_response(semantic_version=VERSION, **overrides): + """Return a mock JSON schema version info response.""" + response = { + "organizationId": ORG_ID_INT, + "organizationName": ORG_NAME, + "schemaId": SCHEMA_ID, + "$id": f"{ORG_NAME}-{SCHEMA_NAME}-{semantic_version}", + "schemaName": SCHEMA_NAME, + "versionId": VERSION_ID, + "semanticVersion": semantic_version, + "jsonSHA256Hex": JSON_SHA_HEX, + "createdOn": CREATED_ON, + "createdBy": CREATED_BY, + } + response.update(overrides) + return response + + +def _get_acl_response(): + """Return a mock ACL response.""" + return { + "id": ORG_ID, + "etag": "acl-etag-123", + "resourceAccess": [ + { + "principalId": PRINCIPAL_ID_1, + "accessType": ["READ", "CREATE"], + } + ], + } + + +class TestCheckName: + """Tests for the _check_name validation function.""" + + def test_valid_name(self) -> None: + # GIVEN a valid name + # WHEN I check the name + # THEN no exception should be raised + _check_name("mytest.organization") + + def test_name_too_short(self) -> None: + # GIVEN a name that is too short + # WHEN I check the name + # THEN it should raise ValueError + with pytest.raises(ValueError, match="length 6 to 250"): + _check_name("abc") + + def test_name_too_long(self) -> None: + # GIVEN a name that is too long + # WHEN I check the name + # THEN it should raise ValueError + with pytest.raises(ValueError, match="length 6 to 250"): + _check_name("a" * 251) + + def test_name_contains_sagebionetworks(self) -> None: + # GIVEN a name containing 'sagebionetworks' + # WHEN I check the name + # THEN it should raise ValueError + with pytest.raises(ValueError, match="sagebionetworks"): + _check_name("my.sagebionetworks.test") + + def test_name_part_starts_with_number(self) -> None: + # GIVEN a name where a part starts with a number + # WHEN I check the name + # THEN it should raise ValueError + with pytest.raises(ValueError, match="must start with a letter"): + _check_name("mytest.1invalid") + + +class TestSchemaOrganization: + """Unit tests for the SchemaOrganization model.""" + + @pytest.fixture(autouse=True, scope="function") + def init_syn(self, syn: Synapse) -> None: + self.syn = syn + + def test_fill_from_dict(self) -> None: + # GIVEN an organization API response + response = _get_organization_response() + + # WHEN I fill a SchemaOrganization from the response + org = SchemaOrganization() + org.fill_from_dict(response) + + # THEN all fields should be populated + assert org.name == ORG_NAME + assert org.id == ORG_ID + assert org.created_on == CREATED_ON + assert org.created_by == CREATED_BY + + async def test_store_async(self) -> None: + # GIVEN a SchemaOrganization with a name + org = SchemaOrganization(name=ORG_NAME) + + # WHEN I call store_async + with patch( + "synapseclient.models.schema_organization.create_organization", + new_callable=AsyncMock, + return_value=_get_organization_response(), + ) as mock_create: + result = await org.store_async(synapse_client=self.syn) + + # THEN the create API should be called with the name + mock_create.assert_called_once_with(ORG_NAME, synapse_client=self.syn) + + # AND the result should be populated + assert result.name == ORG_NAME + assert result.id == ORG_ID + assert result.created_on == CREATED_ON + + async def test_store_async_without_name_raises(self) -> None: + # GIVEN a SchemaOrganization without a name + org = SchemaOrganization() + + # WHEN I call store_async + # THEN it should raise ValueError + with pytest.raises(ValueError, match="must have a name"): + await org.store_async(synapse_client=self.syn) + + async def test_get_async(self) -> None: + # GIVEN a SchemaOrganization with a name + org = SchemaOrganization(name=ORG_NAME) + + # WHEN I call get_async + with patch( + "synapseclient.models.schema_organization.get_organization", + new_callable=AsyncMock, + return_value=_get_organization_response(), + ) as mock_get: + result = await org.get_async(synapse_client=self.syn) + + # THEN the get API should be called with the name + mock_get.assert_called_once_with(ORG_NAME, synapse_client=self.syn) + + # AND the result should be populated + assert result.name == ORG_NAME + assert result.id == ORG_ID + + async def test_get_async_without_name_raises(self) -> None: + # GIVEN a SchemaOrganization without a name + org = SchemaOrganization() + + # WHEN I call get_async + # THEN it should raise ValueError + with pytest.raises(ValueError, match="must have a name"): + await org.get_async(synapse_client=self.syn) + + async def test_delete_async_with_id(self) -> None: + # GIVEN a SchemaOrganization with an id + org = SchemaOrganization(name=ORG_NAME, id=ORG_ID) + + # WHEN I call delete_async + with patch( + "synapseclient.models.schema_organization.delete_organization", + new_callable=AsyncMock, + return_value=None, + ) as mock_delete: + await org.delete_async(synapse_client=self.syn) + + # THEN the delete API should be called with the id + mock_delete.assert_called_once_with( + organization_id=ORG_ID, synapse_client=self.syn + ) + + async def test_delete_async_without_id_triggers_get(self) -> None: + # GIVEN a SchemaOrganization with only a name (no id) + org = SchemaOrganization(name=ORG_NAME) + + # WHEN I call delete_async + with patch( + "synapseclient.models.schema_organization.get_organization", + new_callable=AsyncMock, + return_value=_get_organization_response(), + ) as mock_get, patch( + "synapseclient.models.schema_organization.delete_organization", + new_callable=AsyncMock, + return_value=None, + ) as mock_delete: + await org.delete_async(synapse_client=self.syn) + + # THEN get should be called first to obtain the id + mock_get.assert_called_once_with(ORG_NAME, synapse_client=self.syn) + + # AND then delete should be called with the obtained id + mock_delete.assert_called_once_with( + organization_id=ORG_ID, synapse_client=self.syn + ) + + async def test_get_json_schemas_async(self) -> None: + # GIVEN a SchemaOrganization with a name + org = SchemaOrganization(name=ORG_NAME) + + schema_response_1 = _get_json_schema_list_response() + schema_response_2 = _get_json_schema_list_response( + schemaName="another.schema", schemaId="5002" + ) + + async def mock_list(*args, **kwargs): + yield schema_response_1 + yield schema_response_2 + + # WHEN I call get_json_schemas_async + with patch( + "synapseclient.models.schema_organization.list_json_schemas", + return_value=mock_list(), + ): + results = [] + async for schema in org.get_json_schemas_async(synapse_client=self.syn): + results.append(schema) + + # THEN I should get two JSONSchema objects + assert len(results) == 2 + assert isinstance(results[0], JSONSchema) + assert results[0].name == SCHEMA_NAME + assert results[0].organization_name == ORG_NAME + assert results[0].id == SCHEMA_ID + assert results[1].name == "another.schema" + + async def test_get_json_schemas_async_without_name_raises(self) -> None: + # GIVEN a SchemaOrganization without a name + org = SchemaOrganization() + + # WHEN I call get_json_schemas_async + # THEN it should raise ValueError + with pytest.raises(ValueError, match="must have a name"): + async for _ in org.get_json_schemas_async(synapse_client=self.syn): + pass # pragma: no cover + + async def test_get_acl_async(self) -> None: + # GIVEN a SchemaOrganization with an id + org = SchemaOrganization(name=ORG_NAME, id=ORG_ID) + + acl_response = _get_acl_response() + + # WHEN I call get_acl_async + with patch( + "synapseclient.models.schema_organization.get_organization_acl", + new_callable=AsyncMock, + return_value=acl_response, + ) as mock_get_acl: + result = await org.get_acl_async(synapse_client=self.syn) + + # THEN the ACL API should be called with the id + mock_get_acl.assert_called_once_with(ORG_ID, synapse_client=self.syn) + + # AND the result should contain the ACL data + assert result["etag"] == "acl-etag-123" + assert len(result["resourceAccess"]) == 1 + assert result["resourceAccess"][0]["principalId"] == PRINCIPAL_ID_1 + + async def test_get_acl_async_without_id_triggers_get(self) -> None: + # GIVEN a SchemaOrganization with only a name + org = SchemaOrganization(name=ORG_NAME) + + acl_response = _get_acl_response() + + # WHEN I call get_acl_async (id will be fetched first) + with patch( + "synapseclient.models.schema_organization.get_organization", + new_callable=AsyncMock, + return_value=_get_organization_response(), + ) as mock_get, patch( + "synapseclient.models.schema_organization.get_organization_acl", + new_callable=AsyncMock, + return_value=acl_response, + ) as mock_get_acl: + result = await org.get_acl_async(synapse_client=self.syn) + + # THEN get should be called first to obtain the id + mock_get.assert_called_once() + + # AND get_acl should be called with the obtained id + mock_get_acl.assert_called_once_with(ORG_ID, synapse_client=self.syn) + + async def test_update_acl_async_add_new_principal(self) -> None: + # GIVEN a SchemaOrganization with an id + org = SchemaOrganization(name=ORG_NAME, id=ORG_ID) + + acl_response = _get_acl_response() + + # WHEN I call update_acl_async with a new principal + with patch( + "synapseclient.models.schema_organization.get_organization_acl", + new_callable=AsyncMock, + return_value=acl_response, + ), patch( + "synapseclient.models.schema_organization.update_organization_acl", + new_callable=AsyncMock, + return_value=None, + ) as mock_update: + await org.update_acl_async( + principal_id=PRINCIPAL_ID_2, + access_type=["READ"], + synapse_client=self.syn, + ) + + # THEN the update API should be called with the new principal added + mock_update.assert_called_once() + call_kwargs = mock_update.call_args[1] + resource_access = call_kwargs["resource_access"] + + # AND the resource_access should contain both principals + assert len(resource_access) == 2 + principal_ids = [ra["principalId"] for ra in resource_access] + assert PRINCIPAL_ID_1 in principal_ids + assert PRINCIPAL_ID_2 in principal_ids + + # AND the new principal should have the correct access type + new_entry = next( + ra for ra in resource_access if ra["principalId"] == PRINCIPAL_ID_2 + ) + assert new_entry["accessType"] == ["READ"] + + async def test_update_acl_async_update_existing_principal(self) -> None: + # GIVEN a SchemaOrganization with an id + org = SchemaOrganization(name=ORG_NAME, id=ORG_ID) + + acl_response = _get_acl_response() + + # WHEN I call update_acl_async for an existing principal with new permissions + with patch( + "synapseclient.models.schema_organization.get_organization_acl", + new_callable=AsyncMock, + return_value=acl_response, + ), patch( + "synapseclient.models.schema_organization.update_organization_acl", + new_callable=AsyncMock, + return_value=None, + ) as mock_update: + await org.update_acl_async( + principal_id=PRINCIPAL_ID_1, + access_type=["READ", "CREATE", "DELETE"], + synapse_client=self.syn, + ) + + # THEN the update API should be called + mock_update.assert_called_once() + call_kwargs = mock_update.call_args[1] + resource_access = call_kwargs["resource_access"] + + # AND the resource_access should still have one entry + assert len(resource_access) == 1 + + # AND the existing principal should have updated access types + assert resource_access[0]["principalId"] == PRINCIPAL_ID_1 + assert resource_access[0]["accessType"] == ["READ", "CREATE", "DELETE"] + + # AND the etag should be passed + assert call_kwargs["etag"] == "acl-etag-123" + + +class TestJSONSchema: + """Unit tests for the JSONSchema model.""" + + @pytest.fixture(autouse=True, scope="function") + def init_syn(self, syn: Synapse) -> None: + self.syn = syn + + def test_post_init_sets_uri(self) -> None: + # GIVEN a JSONSchema with name and organization_name + schema = JSONSchema(name=SCHEMA_NAME, organization_name=ORG_NAME) + + # THEN the uri should be set + assert schema.uri == SCHEMA_URI + + def test_post_init_no_uri_without_both_names(self) -> None: + # GIVEN a JSONSchema without organization_name + schema = JSONSchema(name=SCHEMA_NAME) + + # THEN the uri should be None + assert schema.uri is None + + def test_fill_from_dict(self) -> None: + # GIVEN a JSON schema API response + response = _get_json_schema_list_response() + + # WHEN I fill a JSONSchema from the response + schema = JSONSchema() + schema.fill_from_dict(response) + + # THEN all fields should be populated + assert schema.organization_id == ORG_ID_INT + assert schema.organization_name == ORG_NAME + assert schema.id == SCHEMA_ID + assert schema.name == SCHEMA_NAME + assert schema.created_on == CREATED_ON + assert schema.created_by == CREATED_BY + assert schema.uri == SCHEMA_URI + + def test_from_uri_non_semantic(self) -> None: + # GIVEN a non-semantic URI + uri = f"{ORG_NAME}-{SCHEMA_NAME}" + + # WHEN I create a JSONSchema from the URI + schema = JSONSchema.from_uri(uri) + + # THEN the schema should have the correct fields + assert schema.organization_name == ORG_NAME + assert schema.name == SCHEMA_NAME + + def test_from_uri_semantic(self) -> None: + # GIVEN a semantic URI (three parts) + uri = f"{ORG_NAME}-{SCHEMA_NAME}-1.0.0" + + # WHEN I create a JSONSchema from the URI + schema = JSONSchema.from_uri(uri) + + # THEN the schema should have the correct fields + assert schema.organization_name == ORG_NAME + assert schema.name == SCHEMA_NAME + + def test_from_uri_invalid_raises(self) -> None: + # GIVEN an invalid URI with too many parts + uri = "a-b-c-d" + + # WHEN I try to create a JSONSchema from the URI + # THEN it should raise ValueError + with pytest.raises(ValueError, match="must be in the form"): + JSONSchema.from_uri(uri) + + def test_from_uri_too_few_parts_raises(self) -> None: + # GIVEN an invalid URI with too few parts + uri = "onlyonepart" + + # WHEN I try to create a JSONSchema from the URI + # THEN it should raise ValueError + with pytest.raises(ValueError, match="must be in the form"): + JSONSchema.from_uri(uri) + + def test_check_semantic_version_valid(self) -> None: + # GIVEN a JSONSchema + schema = JSONSchema(name=SCHEMA_NAME, organization_name=ORG_NAME) + + # WHEN I check a valid semantic version + # THEN no exception should be raised + schema._check_semantic_version("1.0.0") + schema._check_semantic_version("0.0.1") + schema._check_semantic_version("10.20.30") + + def test_check_semantic_version_zero_raises(self) -> None: + # GIVEN a JSONSchema + schema = JSONSchema(name=SCHEMA_NAME, organization_name=ORG_NAME) + + # WHEN I check version 0.0.0 + # THEN it should raise ValueError + with pytest.raises(ValueError, match="must start at '0.0.1'"): + schema._check_semantic_version("0.0.0") + + def test_check_semantic_version_letters_raises(self) -> None: + # GIVEN a JSONSchema + schema = JSONSchema(name=SCHEMA_NAME, organization_name=ORG_NAME) + + # WHEN I check a version with letters + # THEN it should raise ValueError + with pytest.raises(ValueError, match="semantic version"): + schema._check_semantic_version("1.0.0-beta") + + async def test_store_async_with_schema_body(self) -> None: + # GIVEN a JSONSchema with name and organization_name + schema = JSONSchema(name=SCHEMA_NAME, organization_name=ORG_NAME) + + mock_version_info = JSONSchemaVersionInfo( + organization_id=ORG_ID_INT, + organization_name=ORG_NAME, + schema_id=SCHEMA_ID, + id=f"{ORG_NAME}-{SCHEMA_NAME}", + schema_name=SCHEMA_NAME, + version_id=VERSION_ID, + semantic_version=VERSION, + json_sha256_hex=JSON_SHA_HEX, + created_on=CREATED_ON, + created_by=CREATED_BY, + ) + + mock_completed_request = MagicMock() + mock_completed_request.new_version_info = mock_version_info + + # WHEN I call store_async with a schema body + with patch.object( + CreateSchemaRequest, + "send_job_and_wait_async", + new_callable=AsyncMock, + return_value=mock_completed_request, + ): + self.syn.repoEndpoint = REPO_ENDPOINT + result = await schema.store_async( + schema_body=SCHEMA_BODY.copy(), + synapse_client=self.syn, + ) + + # THEN the result should have updated fields from the version info + assert result.organization_id == ORG_ID_INT + assert result.created_by == CREATED_BY + assert result.created_on == CREATED_ON + + async def test_store_async_with_version(self) -> None: + # GIVEN a JSONSchema with name and organization_name + schema = JSONSchema(name=SCHEMA_NAME, organization_name=ORG_NAME) + + mock_version_info = JSONSchemaVersionInfo( + organization_id=ORG_ID_INT, + organization_name=ORG_NAME, + schema_id=SCHEMA_ID, + id=f"{ORG_NAME}-{SCHEMA_NAME}-{VERSION}", + schema_name=SCHEMA_NAME, + version_id=VERSION_ID, + semantic_version=VERSION, + json_sha256_hex=JSON_SHA_HEX, + created_on=CREATED_ON, + created_by=CREATED_BY, + ) + + mock_completed_request = MagicMock() + mock_completed_request.new_version_info = mock_version_info + + # WHEN I call store_async with a version + with patch.object( + CreateSchemaRequest, + "send_job_and_wait_async", + new_callable=AsyncMock, + return_value=mock_completed_request, + ): + self.syn.repoEndpoint = REPO_ENDPOINT + result = await schema.store_async( + schema_body=SCHEMA_BODY.copy(), + version=VERSION, + synapse_client=self.syn, + ) + + # THEN the result should be populated + assert result.organization_id == ORG_ID_INT + + async def test_store_async_dry_run(self) -> None: + # GIVEN a JSONSchema with name and organization_name + schema = JSONSchema(name=SCHEMA_NAME, organization_name=ORG_NAME) + + mock_version_info = JSONSchemaVersionInfo( + organization_id=ORG_ID_INT, + organization_name=ORG_NAME, + schema_id=SCHEMA_ID, + id=f"{ORG_NAME}-{SCHEMA_NAME}", + schema_name=SCHEMA_NAME, + version_id=VERSION_ID, + semantic_version=VERSION, + json_sha256_hex=JSON_SHA_HEX, + created_on=CREATED_ON, + created_by=CREATED_BY, + ) + + mock_completed_request = MagicMock() + mock_completed_request.new_version_info = mock_version_info + + # WHEN I call store_async with dry_run=True + with patch.object( + CreateSchemaRequest, + "send_job_and_wait_async", + new_callable=AsyncMock, + return_value=mock_completed_request, + ) as mock_send: + self.syn.repoEndpoint = REPO_ENDPOINT + result = await schema.store_async( + schema_body=SCHEMA_BODY.copy(), + dry_run=True, + synapse_client=self.syn, + ) + + # THEN send_job_and_wait_async should be called + mock_send.assert_called_once() + + async def test_store_async_without_name_raises(self) -> None: + # GIVEN a JSONSchema without a name + schema = JSONSchema(organization_name=ORG_NAME) + + # WHEN I call store_async + # THEN it should raise ValueError + with pytest.raises(ValueError, match="must have a name"): + await schema.store_async(schema_body=SCHEMA_BODY, synapse_client=self.syn) + + async def test_store_async_without_org_name_raises(self) -> None: + # GIVEN a JSONSchema without an organization_name + schema = JSONSchema(name=SCHEMA_NAME) + + # WHEN I call store_async + # THEN it should raise ValueError + with pytest.raises(ValueError, match="must have a organization_name"): + await schema.store_async(schema_body=SCHEMA_BODY, synapse_client=self.syn) + + async def test_get_async(self) -> None: + # GIVEN a JSONSchema with name and organization_name + schema = JSONSchema(name=SCHEMA_NAME, organization_name=ORG_NAME) + + schema_response = _get_json_schema_list_response() + + async def mock_list(*args, **kwargs): + yield schema_response + + # WHEN I call get_async (org exists and schema is found) + with patch( + "synapseclient.models.schema_organization.get_organization", + new_callable=AsyncMock, + return_value=_get_organization_response(), + ), patch( + "synapseclient.models.schema_organization.list_json_schemas", + return_value=mock_list(), + ): + result = await schema.get_async(synapse_client=self.syn) + + # THEN the result should be populated + assert result.name == SCHEMA_NAME + assert result.organization_name == ORG_NAME + assert result.id == SCHEMA_ID + + async def test_get_async_schema_not_found_raises(self) -> None: + # GIVEN a JSONSchema with a name that does not exist in the org + schema = JSONSchema(name="nonexistent.schema", organization_name=ORG_NAME) + + # Mock list_json_schemas to return schemas that don't match + other_schema_response = _get_json_schema_list_response( + schemaName="different.schema" + ) + + async def mock_list(*args, **kwargs): + yield other_schema_response + + # WHEN I call get_async + with patch( + "synapseclient.models.schema_organization.get_organization", + new_callable=AsyncMock, + return_value=_get_organization_response(), + ), patch( + "synapseclient.models.schema_organization.list_json_schemas", + return_value=mock_list(), + ): + # THEN it should raise ValueError + with pytest.raises(ValueError, match="does not contain a schema with name"): + await schema.get_async(synapse_client=self.syn) + + async def test_get_async_without_name_raises(self) -> None: + # GIVEN a JSONSchema without a name + schema = JSONSchema(organization_name=ORG_NAME) + + # WHEN I call get_async + # THEN it should raise ValueError + with pytest.raises(ValueError, match="must have a name"): + await schema.get_async(synapse_client=self.syn) + + async def test_get_async_without_org_name_raises(self) -> None: + # GIVEN a JSONSchema without an organization_name + schema = JSONSchema(name=SCHEMA_NAME) + + # WHEN I call get_async + # THEN it should raise ValueError + with pytest.raises(ValueError, match="must have a organization_name"): + await schema.get_async(synapse_client=self.syn) + + async def test_delete_async_without_version(self) -> None: + # GIVEN a JSONSchema with name and organization_name + schema = JSONSchema(name=SCHEMA_NAME, organization_name=ORG_NAME) + + # WHEN I call delete_async without a version + with patch( + "synapseclient.models.schema_organization.delete_json_schema", + new_callable=AsyncMock, + return_value=None, + ) as mock_delete: + await schema.delete_async(synapse_client=self.syn) + + # THEN the delete API should be called with the base URI + mock_delete.assert_called_once_with(SCHEMA_URI, synapse_client=self.syn) + + async def test_delete_async_with_version(self) -> None: + # GIVEN a JSONSchema with name and organization_name + schema = JSONSchema(name=SCHEMA_NAME, organization_name=ORG_NAME) + + # WHEN I call delete_async with a specific version + with patch( + "synapseclient.models.schema_organization.delete_json_schema", + new_callable=AsyncMock, + return_value=None, + ) as mock_delete: + await schema.delete_async(version=VERSION, synapse_client=self.syn) + + # THEN the delete API should be called with the versioned URI + expected_uri = f"{SCHEMA_URI}-{VERSION}" + mock_delete.assert_called_once_with(expected_uri, synapse_client=self.syn) + + async def test_delete_async_without_name_raises(self) -> None: + # GIVEN a JSONSchema without a name + schema = JSONSchema(organization_name=ORG_NAME) + + # WHEN I call delete_async + # THEN it should raise ValueError + with pytest.raises(ValueError, match="must have a name"): + await schema.delete_async(synapse_client=self.syn) + + async def test_delete_async_without_org_name_raises(self) -> None: + # GIVEN a JSONSchema without an organization_name + schema = JSONSchema(name=SCHEMA_NAME) + + # WHEN I call delete_async + # THEN it should raise ValueError + with pytest.raises(ValueError, match="must have a organization_name"): + await schema.delete_async(synapse_client=self.syn) + + async def test_get_versions_async(self) -> None: + # GIVEN a JSONSchema with name and organization_name + schema = JSONSchema(name=SCHEMA_NAME, organization_name=ORG_NAME) + + version_response_1 = _get_schema_version_response(semantic_version="1.0.0") + version_response_2 = _get_schema_version_response(semantic_version="2.0.0") + + async def mock_list(*args, **kwargs): + yield version_response_1 + yield version_response_2 + + # WHEN I call get_versions_async + with patch( + "synapseclient.models.schema_organization.list_json_schema_versions", + return_value=mock_list(), + ): + results = [] + async for version_info in schema.get_versions_async( + synapse_client=self.syn + ): + results.append(version_info) + + # THEN I should get two JSONSchemaVersionInfo objects + assert len(results) == 2 + assert isinstance(results[0], JSONSchemaVersionInfo) + assert results[0].semantic_version == "1.0.0" + assert results[1].semantic_version == "2.0.0" + + async def test_get_versions_async_filters_non_semantic(self) -> None: + # GIVEN a JSONSchema with name and organization_name + schema = JSONSchema(name=SCHEMA_NAME, organization_name=ORG_NAME) + + # One version has a semantic version, one does not + version_with_semantic = _get_schema_version_response(semantic_version="1.0.0") + version_without_semantic = { + "organizationId": ORG_ID_INT, + "organizationName": ORG_NAME, + "schemaId": SCHEMA_ID, + "$id": f"{ORG_NAME}-{SCHEMA_NAME}", + "schemaName": SCHEMA_NAME, + "versionId": "8888", + # Note: no "semanticVersion" key + "jsonSHA256Hex": JSON_SHA_HEX, + "createdOn": CREATED_ON, + "createdBy": CREATED_BY, + } + + async def mock_list(*args, **kwargs): + yield version_with_semantic + yield version_without_semantic + + # WHEN I call get_versions_async + with patch( + "synapseclient.models.schema_organization.list_json_schema_versions", + return_value=mock_list(), + ): + results = [] + async for version_info in schema.get_versions_async( + synapse_client=self.syn + ): + results.append(version_info) + + # THEN only the version with semantic version should be returned + assert len(results) == 1 + assert results[0].semantic_version == "1.0.0" + + async def test_get_body_async_latest(self) -> None: + # GIVEN a JSONSchema with name and organization_name + schema = JSONSchema(name=SCHEMA_NAME, organization_name=ORG_NAME) + + expected_body = {"type": "object", "properties": {"name": {"type": "string"}}} + + # WHEN I call get_body_async without a version (latest) + with patch( + "synapseclient.models.schema_organization.get_json_schema_body", + new_callable=AsyncMock, + return_value=expected_body, + ) as mock_get_body: + result = await schema.get_body_async(synapse_client=self.syn) + + # THEN the API should be called with the base URI + mock_get_body.assert_called_once_with(SCHEMA_URI, synapse_client=self.syn) + + # AND the result should be the schema body + assert result == expected_body + + async def test_get_body_async_with_version(self) -> None: + # GIVEN a JSONSchema with name and organization_name + schema = JSONSchema(name=SCHEMA_NAME, organization_name=ORG_NAME) + + expected_body = {"type": "object"} + + # WHEN I call get_body_async with a specific version + with patch( + "synapseclient.models.schema_organization.get_json_schema_body", + new_callable=AsyncMock, + return_value=expected_body, + ) as mock_get_body: + result = await schema.get_body_async( + version=VERSION, synapse_client=self.syn + ) + + # THEN the API should be called with the versioned URI + expected_uri = f"{SCHEMA_URI}-{VERSION}" + mock_get_body.assert_called_once_with(expected_uri, synapse_client=self.syn) + + # AND the result should be the schema body + assert result == expected_body + + async def test_get_body_async_without_name_raises(self) -> None: + # GIVEN a JSONSchema without a name + schema = JSONSchema(organization_name=ORG_NAME) + + # WHEN I call get_body_async + # THEN it should raise ValueError + with pytest.raises(ValueError, match="must have a name"): + await schema.get_body_async(synapse_client=self.syn) + + async def test_get_body_async_without_org_name_raises(self) -> None: + # GIVEN a JSONSchema without an organization_name + schema = JSONSchema(name=SCHEMA_NAME) + + # WHEN I call get_body_async + # THEN it should raise ValueError + with pytest.raises(ValueError, match="must have a organization_name"): + await schema.get_body_async(synapse_client=self.syn) + + +class TestCreateSchemaRequest: + """Tests for the CreateSchemaRequest helper dataclass.""" + + def test_post_init_sets_fields(self) -> None: + # GIVEN schema request parameters + body = SCHEMA_BODY.copy() + + # WHEN I create a CreateSchemaRequest + request = CreateSchemaRequest( + schema=body, + name=SCHEMA_NAME, + organization_name=ORG_NAME, + synapse_schema_url=f"{REPO_ENDPOINT}/schema/type/registered/", + ) + + # THEN the fields should be set correctly + assert request.uri == SCHEMA_URI + assert request.id == f"{REPO_ENDPOINT}/schema/type/registered/{SCHEMA_URI}" + assert body["$id"] == request.id + + def test_post_init_with_version(self) -> None: + # GIVEN schema request parameters with a version + body = SCHEMA_BODY.copy() + + # WHEN I create a CreateSchemaRequest with version + request = CreateSchemaRequest( + schema=body, + name=SCHEMA_NAME, + organization_name=ORG_NAME, + version=VERSION, + synapse_schema_url=f"{REPO_ENDPOINT}/schema/type/registered/", + ) + + # THEN the URI should include the version + expected_uri = f"{SCHEMA_URI}-{VERSION}" + assert request.uri == expected_uri + + def test_to_synapse_request(self) -> None: + # GIVEN a CreateSchemaRequest + body = SCHEMA_BODY.copy() + request = CreateSchemaRequest( + schema=body, + name=SCHEMA_NAME, + organization_name=ORG_NAME, + dry_run=True, + synapse_schema_url=f"{REPO_ENDPOINT}/schema/type/registered/", + ) + + # WHEN I convert it to a synapse request + result = request.to_synapse_request() + + # THEN it should have the correct structure + assert "concreteType" in result + assert result["schema"] == body + assert result["dryRun"] is True + + def test_fill_from_dict(self) -> None: + # GIVEN a CreateSchemaRequest and an API response + body = SCHEMA_BODY.copy() + request = CreateSchemaRequest( + schema=body, + name=SCHEMA_NAME, + organization_name=ORG_NAME, + synapse_schema_url=f"{REPO_ENDPOINT}/schema/type/registered/", + ) + + response = { + "newVersionInfo": _get_schema_version_response(), + "validationSchema": {"$id": "validated", "type": "object"}, + } + + # WHEN I fill from the response + request.fill_from_dict(response) + + # THEN the new_version_info should be populated + assert request.new_version_info is not None + assert request.new_version_info.organization_id == ORG_ID_INT + assert request.new_version_info.semantic_version == VERSION + assert request.schema == {"$id": "validated", "type": "object"} diff --git a/tests/unit/synapseclient/models/synchronous/unit_test_activity.py b/tests/unit/synapseclient/models/synchronous/unit_test_activity.py deleted file mode 100644 index 79324c1fc..000000000 --- a/tests/unit/synapseclient/models/synchronous/unit_test_activity.py +++ /dev/null @@ -1,344 +0,0 @@ -"""Unit tests for Activity.""" - -from unittest.mock import AsyncMock, patch - -import pytest - -from synapseclient.activity import Activity as Synapse_Activity -from synapseclient.core.constants.concrete_types import USED_ENTITY, USED_URL -from synapseclient.models import Activity, File, UsedEntity, UsedURL - -ACTIVITY_NAME = "some_name" -DESCRIPTION = "some_description" -BOGUS_URL = "https://www.synapse.org/" -CREATED_ON = "2022-01-01T00:00:00Z" -MODIFIED_ON = "2022-01-02T00:00:00Z" -CREATED_BY = "user1" -MODIFIED_BY = "user2" -ETAG = "some_etag" -SYN_123 = "syn123" -SYN_456 = "syn456" -SYN_789 = "syn789" -EXAMPLE_NAME = "example" - - -class TestActivity: - """Unit tests for Activity.""" - - @pytest.fixture(autouse=True, scope="function") - def init_syn(self, syn): - self.syn = syn - - def get_example_synapse_activity_output(self) -> Synapse_Activity: - synapse_activity = Synapse_Activity( - name=ACTIVITY_NAME, - description=DESCRIPTION, - used=[ - { - "wasExecuted": False, - "concreteType": USED_URL, - "url": BOGUS_URL, - "name": EXAMPLE_NAME, - }, - { - "wasExecuted": False, - "concreteType": USED_ENTITY, - "reference": { - "targetId": SYN_456, - "targetVersionNumber": 1, - }, - }, - ], - executed=[ - { - "wasExecuted": True, - "concreteType": USED_URL, - "url": BOGUS_URL, - "name": EXAMPLE_NAME, - }, - { - "wasExecuted": True, - "concreteType": USED_ENTITY, - "reference": { - "targetId": SYN_789, - "targetVersionNumber": 1, - }, - }, - ], - ) - synapse_activity["id"] = SYN_123 - synapse_activity["etag"] = ETAG - synapse_activity["createdOn"] = CREATED_ON - synapse_activity["modifiedOn"] = MODIFIED_ON - synapse_activity["createdBy"] = CREATED_BY - synapse_activity["modifiedBy"] = MODIFIED_BY - return synapse_activity - - def test_fill_from_dict(self) -> None: - # GIVEN a blank activity - activity = Activity() - - # WHEN we fill it from a dictionary with all fields - activity.fill_from_dict( - synapse_activity=self.get_example_synapse_activity_output() - ) - - # THEN the activity should have all fields filled - assert activity.id == SYN_123 - assert activity.etag == ETAG - assert activity.name == ACTIVITY_NAME - assert activity.description == DESCRIPTION - assert activity.created_on == CREATED_ON - assert activity.modified_on == MODIFIED_ON - assert activity.created_by == CREATED_BY - assert activity.modified_by == MODIFIED_BY - assert len(activity.used) == 2 - assert isinstance(activity.used[0], UsedURL) - assert activity.used[0].url == BOGUS_URL - assert activity.used[0].name == EXAMPLE_NAME - assert isinstance(activity.used[1], UsedEntity) - assert activity.used[1].target_id == SYN_456 - assert activity.used[1].target_version_number == 1 - assert len(activity.executed) == 2 - assert isinstance(activity.executed[0], UsedURL) - assert activity.executed[0].url == BOGUS_URL - assert activity.executed[0].name == EXAMPLE_NAME - assert isinstance(activity.executed[1], UsedEntity) - assert activity.executed[1].target_id == SYN_789 - assert activity.executed[1].target_version_number == 1 - - def test_store_with_id(self) -> None: - # GIVEN an activity with an id - activity = Activity( - id=SYN_123, - name=ACTIVITY_NAME, - description=DESCRIPTION, - used=[ - UsedURL(name=EXAMPLE_NAME, url=BOGUS_URL), - UsedEntity(target_id=SYN_456, target_version_number=1), - ], - executed=[ - UsedURL(name=EXAMPLE_NAME, url=BOGUS_URL), - UsedEntity(target_id=SYN_789, target_version_number=1), - ], - ) - - # WHEN we store it - with patch( - "synapseclient.models.activity.update_activity", - new_callable=AsyncMock, - return_value=(self.get_example_synapse_activity_output()), - ) as path_update_activity: - result_of_store = activity.store(synapse_client=self.syn) - - # THEN we should call the method with this data - expected_request = { - "id": SYN_123, - "name": ACTIVITY_NAME, - "description": DESCRIPTION, - "used": [ - { - "concreteType": USED_URL, - "name": EXAMPLE_NAME, - "url": BOGUS_URL, - "wasExecuted": False, - }, - { - "concreteType": USED_ENTITY, - "reference": { - "targetId": SYN_456, - "targetVersionNumber": 1, - }, - "wasExecuted": False, - }, - { - "concreteType": USED_URL, - "name": EXAMPLE_NAME, - "url": BOGUS_URL, - "wasExecuted": True, - }, - { - "concreteType": USED_ENTITY, - "reference": { - "targetId": SYN_789, - "targetVersionNumber": 1, - }, - "wasExecuted": True, - }, - ], - } - path_update_activity.assert_called_once_with( - expected_request, synapse_client=self.syn - ) - - # AND we should get back the stored activity - assert result_of_store.id == SYN_123 - assert result_of_store.etag == ETAG - assert result_of_store.name == ACTIVITY_NAME - assert result_of_store.description == DESCRIPTION - assert result_of_store.created_on == CREATED_ON - assert result_of_store.modified_on == MODIFIED_ON - assert result_of_store.created_by == CREATED_BY - assert result_of_store.modified_by == MODIFIED_BY - assert len(result_of_store.used) == 2 - assert isinstance(result_of_store.used[0], UsedURL) - assert result_of_store.used[0].url == BOGUS_URL - assert result_of_store.used[0].name == EXAMPLE_NAME - assert isinstance(result_of_store.used[1], UsedEntity) - assert result_of_store.used[1].target_id == SYN_456 - assert result_of_store.used[1].target_version_number == 1 - assert len(result_of_store.executed) == 2 - assert isinstance(result_of_store.executed[0], UsedURL) - assert result_of_store.executed[0].url == BOGUS_URL - assert result_of_store.executed[0].name == EXAMPLE_NAME - assert isinstance(result_of_store.executed[1], UsedEntity) - assert result_of_store.executed[1].target_id == SYN_789 - assert result_of_store.executed[1].target_version_number == 1 - - def test_store_with_parent(self) -> None: - # GIVEN an activity with a parent - activity = Activity( - name=ACTIVITY_NAME, - description=DESCRIPTION, - used=[ - UsedURL(name=EXAMPLE_NAME, url=BOGUS_URL), - UsedEntity(target_id=SYN_456, target_version_number=1), - ], - executed=[ - UsedURL(name=EXAMPLE_NAME, url=BOGUS_URL), - UsedEntity(target_id=SYN_789, target_version_number=1), - ], - ) - - # WHEN we store it - with patch( - "synapseclient.models.activity.set_entity_provenance", - return_value=(self.get_example_synapse_activity_output()), - ) as path_set_provenance: - result_of_store = activity.store( - parent=File("syn999"), synapse_client=self.syn - ) - - # THEN we should call the method with this data - expected_request = { - "name": ACTIVITY_NAME, - "description": DESCRIPTION, - "used": [ - { - "concreteType": USED_URL, - "name": EXAMPLE_NAME, - "url": BOGUS_URL, - "wasExecuted": False, - }, - { - "concreteType": USED_ENTITY, - "reference": { - "targetId": SYN_456, - "targetVersionNumber": 1, - }, - "wasExecuted": False, - }, - { - "concreteType": USED_URL, - "name": EXAMPLE_NAME, - "url": BOGUS_URL, - "wasExecuted": True, - }, - { - "concreteType": USED_ENTITY, - "reference": { - "targetId": SYN_789, - "targetVersionNumber": 1, - }, - "wasExecuted": True, - }, - ], - } - path_set_provenance.assert_called_once_with( - entity_id="syn999", - activity=expected_request, - synapse_client=self.syn, - ) - - # AND we should get back the stored activity - assert result_of_store.id == SYN_123 - assert result_of_store.etag == ETAG - assert result_of_store.name == ACTIVITY_NAME - assert result_of_store.description == DESCRIPTION - assert result_of_store.created_on == CREATED_ON - assert result_of_store.modified_on == MODIFIED_ON - assert result_of_store.created_by == CREATED_BY - assert result_of_store.modified_by == MODIFIED_BY - assert len(result_of_store.used) == 2 - assert isinstance(result_of_store.used[0], UsedURL) - assert result_of_store.used[0].url == BOGUS_URL - assert result_of_store.used[0].name == EXAMPLE_NAME - assert isinstance(result_of_store.used[1], UsedEntity) - assert result_of_store.used[1].target_id == SYN_456 - assert result_of_store.used[1].target_version_number == 1 - assert len(result_of_store.executed) == 2 - assert isinstance(result_of_store.executed[0], UsedURL) - assert result_of_store.executed[0].url == BOGUS_URL - assert result_of_store.executed[0].name == EXAMPLE_NAME - assert isinstance(result_of_store.executed[1], UsedEntity) - assert result_of_store.executed[1].target_id == SYN_789 - assert result_of_store.executed[1].target_version_number == 1 - - def test_from_parent(self) -> None: - # GIVEN a parent with an activity - parent = File("syn999", version_number=1) - - # WHEN I get the activity - with patch( - "synapseclient.models.activity.get_entity_provenance", - return_value=(self.get_example_synapse_activity_output()), - ) as path_get_provenance: - result_of_get = Activity.from_parent(parent=parent, synapse_client=self.syn) - - # THEN we should call the method with this data - path_get_provenance.assert_called_once_with( - entity_id="syn999", - version_number=1, - synapse_client=self.syn, - ) - - # AND we should get back the stored activity - assert result_of_get.id == SYN_123 - assert result_of_get.etag == ETAG - assert result_of_get.name == ACTIVITY_NAME - assert result_of_get.description == DESCRIPTION - assert result_of_get.created_on == CREATED_ON - assert result_of_get.modified_on == MODIFIED_ON - assert result_of_get.created_by == CREATED_BY - assert result_of_get.modified_by == MODIFIED_BY - assert len(result_of_get.used) == 2 - assert isinstance(result_of_get.used[0], UsedURL) - assert result_of_get.used[0].url == BOGUS_URL - assert result_of_get.used[0].name == EXAMPLE_NAME - assert isinstance(result_of_get.used[1], UsedEntity) - assert result_of_get.used[1].target_id == SYN_456 - assert result_of_get.used[1].target_version_number == 1 - assert len(result_of_get.executed) == 2 - assert isinstance(result_of_get.executed[0], UsedURL) - assert result_of_get.executed[0].url == BOGUS_URL - assert result_of_get.executed[0].name == EXAMPLE_NAME - assert isinstance(result_of_get.executed[1], UsedEntity) - assert result_of_get.executed[1].target_id == SYN_789 - assert result_of_get.executed[1].target_version_number == 1 - - def test_delete(self) -> None: - # GIVEN a parent with an activity - parent = File(id="syn999") - - # WHEN I delete the activity - with patch( - "synapseclient.models.activity.delete_entity_provenance", - return_value=None, - ) as path_delete_provenance: - Activity.delete(parent=parent, synapse_client=self.syn) - - # THEN we should call the method with this data - path_delete_provenance.assert_called_once_with( - entity_id="syn999", - synapse_client=self.syn, - ) diff --git a/tests/unit/synapseclient/models/synchronous/unit_test_agent.py b/tests/unit/synapseclient/models/synchronous/unit_test_agent.py deleted file mode 100644 index 014720c94..000000000 --- a/tests/unit/synapseclient/models/synchronous/unit_test_agent.py +++ /dev/null @@ -1,595 +0,0 @@ -"""Unit tests for Synchronous methods in Agent, AgentSession, and AgentPrompt classes.""" - -from unittest.mock import patch - -import pytest - -from synapseclient import Synapse -from synapseclient.core.constants.concrete_types import AGENT_CHAT_REQUEST -from synapseclient.models.agent import ( - Agent, - AgentPrompt, - AgentSession, - AgentSessionAccessLevel, - AgentType, -) - - -class TestAgentPrompt: - """Unit tests for the AgentPrompt class' synchronous methods.""" - - test_prompt = AgentPrompt( - concrete_type=AGENT_CHAT_REQUEST, - session_id="456", - prompt="Hello", - enable_trace=True, - ) - prompt_request = { - "concreteType": test_prompt.concrete_type, - "sessionId": test_prompt.session_id, - "chatText": test_prompt.prompt, - "enableTrace": test_prompt.enable_trace, - } - prompt_response = { - "jobId": "123", - "sessionId": "456", - "responseText": "World", - } - - def test_to_synapse_request(self) -> None: - # GIVEN an existing AgentPrompt - # WHEN I call to_synapse_request - result_request = self.test_prompt.to_synapse_request() - # THEN the result should be a dictionary with the correct keys and values - assert result_request == self.prompt_request - - def test_fill_from_dict(self) -> None: - # GIVEN an existing AgentPrompt - # WHEN I call fill_from_dict - result_prompt = self.test_prompt.fill_from_dict(self.prompt_response) - # THEN the result should be an AgentPrompt with the correct values - assert result_prompt == self.test_prompt - - -class TestAgentSession: - """Unit tests for the AgentSession class' synchronous methods.""" - - test_session = AgentSession( - id="123", - access_level=AgentSessionAccessLevel.PUBLICLY_ACCESSIBLE, - started_on="2024-01-01T00:00:00Z", - started_by="123456789", - modified_on="2024-01-01T00:00:00Z", - agent_registration_id="0", - etag="11111111-1111-1111-1111-111111111111", - ) - - session_response = { - "sessionId": test_session.id, - "agentAccessLevel": test_session.access_level, - "startedOn": test_session.started_on, - "startedBy": test_session.started_by, - "modifiedOn": test_session.modified_on, - "agentRegistrationId": test_session.agent_registration_id, - "etag": test_session.etag, - } - - updated_test_session = AgentSession( - id=test_session.id, - access_level=AgentSessionAccessLevel.READ_YOUR_PRIVATE_DATA, - started_on=test_session.started_on, - started_by=test_session.started_by, - modified_on=test_session.modified_on, - agent_registration_id=test_session.agent_registration_id, - etag=test_session.etag, - ) - - updated_session_response = { - "sessionId": updated_test_session.id, - "agentAccessLevel": updated_test_session.access_level, - "startedOn": updated_test_session.started_on, - "startedBy": updated_test_session.started_by, - "modifiedOn": updated_test_session.modified_on, - "agentRegistrationId": updated_test_session.agent_registration_id, - "etag": updated_test_session.etag, - } - - test_prompt_trace_enabled = AgentPrompt( - concrete_type=AGENT_CHAT_REQUEST, - session_id="456", - prompt="Hello", - enable_trace=True, - response="World", - trace="Trace", - ) - - test_prompt_trace_disabled = AgentPrompt( - concrete_type=AGENT_CHAT_REQUEST, - session_id="456", - prompt="Hello", - enable_trace=False, - response="World", - trace=None, - ) - - @pytest.fixture(autouse=True, scope="function") - def init_syn(self, syn: Synapse) -> None: - self.syn = syn - - def test_fill_from_dict(self) -> None: - # WHEN I call fill_from_dict on an empty AgentSession with a synapse_response - result_session = AgentSession().fill_from_dict(self.session_response) - # THEN the result should be an AgentSession with the correct values - assert result_session == self.test_session - - def test_start(self) -> None: - with ( - patch( - "synapseclient.models.agent.start_session", - return_value=self.session_response, - ) as mock_start_session, - patch.object( - AgentSession, - "fill_from_dict", - return_value=self.test_session, - ) as mock_fill_from_dict, - ): - # GIVEN an AgentSession with access_level and agent_registration_id - initial_session = AgentSession( - access_level=AgentSessionAccessLevel.PUBLICLY_ACCESSIBLE, - agent_registration_id=0, - ) - # WHEN I call start - result_session = initial_session.start(synapse_client=self.syn) - # THEN the result should be an AgentSession with the correct values - assert result_session == self.test_session - # AND start_session should have been called once with the correct arguments - mock_start_session.assert_called_once_with( - access_level=AgentSessionAccessLevel.PUBLICLY_ACCESSIBLE, - agent_registration_id=0, - synapse_client=self.syn, - ) - # AND fill_from_dict should have been called once with the correct arguments - mock_fill_from_dict.assert_called_once_with( - synapse_agent_session=self.session_response - ) - - def test_get(self) -> None: - with ( - patch( - "synapseclient.models.agent.get_session", - return_value=self.session_response, - ) as mock_get_session, - patch.object( - AgentSession, - "fill_from_dict", - return_value=self.test_session, - ) as mock_fill_from_dict, - ): - # GIVEN an AgentSession with an agent_registration_id - initial_session = AgentSession( - agent_registration_id=0, - ) - # WHEN I call get - result_session = initial_session.get(synapse_client=self.syn) - # THEN the result should be an AgentSession with the correct values - assert result_session == self.test_session - # AND get_session should have been called once with the correct arguments - mock_get_session.assert_called_once_with( - id=initial_session.id, - synapse_client=self.syn, - ) - # AND fill_from_dict should have been called once with the correct arguments - mock_fill_from_dict.assert_called_once_with( - synapse_agent_session=self.session_response - ) - - def test_update(self) -> None: - with ( - patch( - "synapseclient.models.agent.update_session", - return_value=self.updated_session_response, - ) as mock_update_session, - patch.object( - AgentSession, - "fill_from_dict", - return_value=self.updated_test_session, - ) as mock_fill_from_dict, - ): - # GIVEN an AgentSession with an updated access_level - # WHEN I call update - result_session = self.updated_test_session.update(synapse_client=self.syn) - # THEN the result should be an AgentSession with the correct values - assert result_session == self.updated_test_session - # AND update_session should have been called once with the correct arguments - mock_update_session.assert_called_once_with( - id=self.updated_test_session.id, - access_level=AgentSessionAccessLevel.READ_YOUR_PRIVATE_DATA, - synapse_client=self.syn, - ) - # AND fill_from_dict should have been called once with the correct arguments - mock_fill_from_dict.assert_called_once_with( - synapse_agent_session=self.updated_session_response - ) - - def test_prompt_trace_enabled_print_response(self) -> None: - with ( - patch( - "synapseclient.models.agent.AgentPrompt.send_job_and_wait_async", - return_value=self.test_prompt_trace_enabled, - ) as mock_send_job_and_wait_async, - patch.object( - self.syn.logger, - "info", - ) as mock_logger_info, - ): - # GIVEN an existing AgentSession - # WHEN I call prompt with trace enabled and print_response enabled - self.test_session.prompt( - prompt="Hello", - enable_trace=True, - print_response=True, - newer_than=0, - synapse_client=self.syn, - ) - # THEN the result should be an AgentPrompt with the correct values appended to the chat history - assert self.test_prompt_trace_enabled in self.test_session.chat_history - # AND send_job_and_wait_async should have been called once with the correct arguments - mock_send_job_and_wait_async.assert_called_once_with( - timeout=120, - synapse_client=self.syn, - post_exchange_args={"newer_than": 0}, - ) - # AND the trace should be printed - mock_logger_info.assert_called_with( - f"TRACE:\n{self.test_prompt_trace_enabled.trace}" - ) - - def test_prompt_trace_disabled_no_print(self) -> None: - with ( - patch( - "synapseclient.models.agent.AgentPrompt.send_job_and_wait_async", - return_value=self.test_prompt_trace_disabled, - ) as mock_send_job_and_wait_async, - patch.object( - self.syn.logger, - "info", - ) as mock_logger_info, - ): - # WHEN I call prompt with trace disabled and print_response disabled - self.test_session.prompt( - prompt="Hello", - enable_trace=False, - print_response=False, - newer_than=0, - synapse_client=self.syn, - ) - # THEN the result should be an AgentPrompt with the correct values appended to the chat history - assert self.test_prompt_trace_disabled in self.test_session.chat_history - # AND send_job_and_wait_async should have been called once with the correct arguments - mock_send_job_and_wait_async.assert_called_once_with( - timeout=120, - synapse_client=self.syn, - post_exchange_args={"newer_than": 0}, - ) - # AND print should not have been called - mock_logger_info.assert_not_called() - - -class TestAgent: - """Unit tests for the Agent class' synchronous methods.""" - - def get_example_agent(self) -> Agent: - return Agent( - cloud_agent_id="123", - cloud_alias_id="456", - registration_id=0, - type=AgentType.BASELINE, - registered_on="2024-01-01T00:00:00Z", - sessions={}, - current_session=None, - ) - - test_agent = Agent( - cloud_agent_id="123", - cloud_alias_id="456", - registration_id=0, - type=AgentType.BASELINE, - registered_on="2024-01-01T00:00:00Z", - sessions={}, - current_session=None, - ) - - agent_response = { - "awsAgentId": test_agent.cloud_agent_id, - "awsAliasId": test_agent.cloud_alias_id, - "agentRegistrationId": test_agent.registration_id, - "registeredOn": test_agent.registered_on, - "type": test_agent.type, - } - - test_session = AgentSession( - id="123", - access_level=AgentSessionAccessLevel.PUBLICLY_ACCESSIBLE, - started_on="2024-01-01T00:00:00Z", - started_by="123456789", - modified_on="2024-01-01T00:00:00Z", - agent_registration_id="0", - etag="11111111-1111-1111-1111-111111111111", - ) - - test_prompt = AgentPrompt( - concrete_type=AGENT_CHAT_REQUEST, - session_id="456", - prompt="Hello", - enable_trace=True, - response="World", - trace="Trace", - ) - - @pytest.fixture(autouse=True, scope="function") - def init_syn(self, syn: Synapse) -> None: - self.syn = syn - - def test_fill_from_dict(self) -> None: - # GIVEN an empty Agent - empty_agent = Agent() - # WHEN I call fill_from_dict on an empty Agent with a synapse_response - result_agent = empty_agent.fill_from_dict( - agent_registration=self.agent_response - ) - # THEN the result should be an Agent with the correct values - assert result_agent == self.test_agent - - def test_register(self) -> None: - with ( - patch( - "synapseclient.models.agent.register_agent", - return_value=self.agent_response, - ) as mock_register_agent, - patch.object( - Agent, - "fill_from_dict", - return_value=self.test_agent, - ) as mock_fill_from_dict, - ): - # GIVEN an Agent with a cloud_agent_id - initial_agent = Agent( - cloud_agent_id="123", - cloud_alias_id="456", - ) - # WHEN I call register - result_agent = initial_agent.register(synapse_client=self.syn) - # THEN the result should be an Agent with the correct values - assert result_agent == self.test_agent - # AND register_agent should have been called once with the correct arguments - mock_register_agent.assert_called_once_with( - cloud_agent_id="123", - cloud_alias_id="456", - synapse_client=self.syn, - ) - # AND fill_from_dict should have been called once with the correct arguments - mock_fill_from_dict.assert_called_once_with( - agent_registration=self.agent_response - ) - - def test_get(self) -> None: - with ( - patch( - "synapseclient.models.agent.get_agent", - return_value=self.agent_response, - ) as mock_get_agent, - patch.object( - Agent, - "fill_from_dict", - return_value=self.test_agent, - ) as mock_fill_from_dict, - ): - # GIVEN an Agent with a registration_id - initial_agent = Agent( - registration_id=0, - ) - # WHEN I call get - result_agent = initial_agent.get(synapse_client=self.syn) - # THEN the result should be an Agent with the correct values - assert result_agent == self.test_agent - # AND get_agent should have been called once with the correct arguments - mock_get_agent.assert_called_once_with( - registration_id=0, - synapse_client=self.syn, - ) - # AND fill_from_dict should have been called once with the correct arguments - mock_fill_from_dict.assert_called_once_with( - agent_registration=self.agent_response - ) - - def test_start_session(self) -> None: - with patch.object( - AgentSession, - "start_async", - return_value=self.test_session, - ) as mock_start_session: - # GIVEN an existing Agent - my_agent = self.get_example_agent() - # WHEN I call start_session - result_session = my_agent.start_session( - access_level=AgentSessionAccessLevel.PUBLICLY_ACCESSIBLE, - synapse_client=self.syn, - ) - # THEN the result should be an AgentSession with the correct values - assert result_session == self.test_session - # AND start_session should have been called once with the correct arguments - mock_start_session.assert_called_once_with( - synapse_client=self.syn, - ) - # AND the current_session should be set to the new session - assert my_agent.current_session == self.test_session - # AND the sessions dictionary should have the new session - assert my_agent.sessions[self.test_session.id] == self.test_session - - def test_get_session(self) -> None: - with patch.object( - AgentSession, - "get_async", - return_value=self.test_session, - ) as mock_get_session: - # GIVEN an existing AgentSession - my_agent = self.get_example_agent() - # WHEN I call get_session - result_session = my_agent.get_session( - session_id="123", synapse_client=self.syn - ) - # THEN the result should be an AgentSession with the correct values - assert result_session == self.test_session - # AND get_session should have been called once with the correct arguments - mock_get_session.assert_called_once_with( - synapse_client=self.syn, - ) - # AND the current_session should be set to the session - assert my_agent.current_session == self.test_session - # AND the sessions dictionary should have the session - assert my_agent.sessions[self.test_session.id] == self.test_session - - def test_prompt_session_selected(self) -> None: - with ( - patch.object( - AgentSession, - "get_async", - return_value=self.test_session, - ) as mock_get_async, - patch.object( - Agent, - "start_session_async", - ) as mock_start_session, - patch.object( - AgentSession, - "prompt_async", - ) as mock_prompt_async, - ): - # GIVEN an existing AgentSession - my_agent = self.get_example_agent() - # WHEN I call prompt with a session selected - my_agent.prompt( - prompt="Hello", - enable_trace=True, - print_response=True, - session=self.test_session, - newer_than=0, - synapse_client=self.syn, - ) - # AND get_session_async should have been called once with the correct arguments - mock_get_async.assert_called_once_with( - synapse_client=self.syn, - ) - # AND start_session_async should not have been called - mock_start_session.assert_not_called() - # AND prompt_async should have been called once with the correct arguments - mock_prompt_async.assert_called_once_with( - prompt="Hello", - enable_trace=True, - newer_than=0, - print_response=True, - timeout=120, - synapse_client=self.syn, - ) - - def test_prompt_session_none_current_session_none(self) -> None: - with ( - patch.object( - Agent, - "get_session_async", - ) as mock_get_session, - patch.object( - AgentSession, - "start_async", - return_value=self.test_session, - ) as mock_start_async, - patch.object( - AgentSession, - "prompt_async", - ) as mock_prompt_async, - ): - # GIVEN an existing Agent with no current session - my_agent = self.get_example_agent() - # WHEN I call prompt with no session selected and no current session set - my_agent.prompt( - prompt="Hello", - enable_trace=True, - print_response=True, - newer_than=0, - synapse_client=self.syn, - ) - # THEN get_session_async should not have been called - mock_get_session.assert_not_called() - # AND start_session_async should have been called once with the correct arguments - mock_start_async.assert_called_once_with( - synapse_client=self.syn, - ) - # AND prompt_async should have been called once with the correct arguments - mock_prompt_async.assert_called_once_with( - prompt="Hello", - enable_trace=True, - newer_than=0, - print_response=True, - timeout=120, - synapse_client=self.syn, - ) - - def test_prompt_session_none_current_session_present(self) -> None: - with ( - patch.object( - Agent, - "get_session_async", - ) as mock_get_session, - patch.object( - AgentSession, - "start_async", - ) as mock_start_async, - patch.object( - AgentSession, - "prompt_async", - ) as mock_prompt_async, - ): - # GIVEN an existing Agent with a current session - my_agent = self.get_example_agent() - my_agent.current_session = self.test_session - # WHEN I call prompt with no session selected and a current session set - my_agent.prompt( - prompt="Hello", - enable_trace=True, - newer_than=0, - print_response=True, - synapse_client=self.syn, - ) - # THEN get_session_async and start_session_async should not have been called - mock_get_session.assert_not_called() - mock_start_async.assert_not_called() - # AND prompt_async should have been called once with the correct arguments - mock_prompt_async.assert_called_once_with( - prompt="Hello", - enable_trace=True, - newer_than=0, - print_response=True, - timeout=120, - synapse_client=self.syn, - ) - - def test_get_chat_history_when_current_session_none(self) -> None: - # GIVEN an existing Agent with no current session - my_agent = self.get_example_agent() - # WHEN I call get_chat_history - result_chat_history = my_agent.get_chat_history() - # THEN the result should be None - assert result_chat_history is None - - def test_get_chat_history_when_current_session_and_chat_history_present( - self, - ) -> None: - # GIVEN an existing Agent with a current session and chat history - my_agent = self.get_example_agent() - my_agent.current_session = self.test_session - my_agent.current_session.chat_history = [self.test_prompt] - # WHEN I call get_chat_history - result_chat_history = my_agent.get_chat_history() - # THEN the result should be the chat history - assert self.test_prompt in result_chat_history diff --git a/tests/unit/synapseclient/models/synchronous/unit_test_file.py b/tests/unit/synapseclient/models/synchronous/unit_test_file.py deleted file mode 100644 index 9dbfe5839..000000000 --- a/tests/unit/synapseclient/models/synchronous/unit_test_file.py +++ /dev/null @@ -1,1024 +0,0 @@ -"""Unit tests for the File model""" -import os -from typing import Dict, Union -from unittest.mock import AsyncMock, patch - -import pytest - -from synapseclient import File as Synapse_File -from synapseclient.core import utils -from synapseclient.core.constants import concrete_types -from synapseclient.models import Activity, File, Project, UsedURL - -SYN_123 = "syn123" -FILE_NAME = "example_file.txt" -PATH = "/asdf/example_file.txt" -DESCRIPTION = "This is an example file." -ETAG = "etag_value" -CREATED_ON = "createdOn_value" -MODIFIED_ON = "modifiedOn_value" -CREATED_BY = "createdBy_value" -MODIFIED_BY = "modifiedBy_value" -PARENT_ID = "parent_id_value" -VERSION_LABEL = "v1" -VERSION_COMMENT = "This is version 1." -DATA_FILE_HANDLE_ID = 888 -FILE_HANDLE_ID = 888 -FILE_HANDLE_ETAG = "file_handle_etag_value" -FILE_HANDLE_CREATED_BY = "file_handle_createdBy_value" -FILE_HANDLE_CREATED_ON = "file_handle_createdOn_value" -FILE_HANDLE_MODIFIED_ON = "file_handle_modifiedOn_value" -FILE_HANDLE_CONCRETE_TYPE = "file_handle_concreteType_value" -FILE_HANDLE_CONTENT_TYPE = "file_handle_contentType_value" -FILE_HANDLE_CONTENT_MD5 = "file_handle_contentMd5_value" -FILE_HANDLE_CONTENT_SIZE = 123 -FILE_HANDLE_FILE_NAME = "file_handle_fileName_value" -FILE_HANDLE_STORAGE_LOCATION_ID = "file_handle_storageLocationId_value" -FILE_HANDLE_STATUS = "file_handle_status_value" -FILE_HANDLE_BUCKET_NAME = "file_handle_bucketName_value" -FILE_HANDLE_KEY = "file_handle_key_value" -FILE_HANDLE_PREVIEW_ID = "file_handle_previewId_value" -FILE_HANDLE_EXTERNAL_URL = "file_handle_externalURL_value" - -MODIFIED_DESCRIPTION = "This is a modified description." -ACTUAL_PARENT_ID = "syn999" - -CANNOT_STORE_FILE_ERROR = """Cannot store file. The file must have one of: - 1. An ID and (path, external_url, or data_file_handle_id) - 2. A (path or external_url) and parent_id - 3. A data_file_handle_id and parent_id""" - - -class TestFile: - """Tests for the File model.""" - - @pytest.fixture(autouse=True, scope="function") - def init_syn(self, syn): - self.syn = syn - - def get_example_synapse_file(self) -> Synapse_File: - return Synapse_File( - id=SYN_123, - name=FILE_NAME, - path=PATH, - description=DESCRIPTION, - etag=ETAG, - createdOn=CREATED_ON, - modifiedOn=MODIFIED_ON, - createdBy=CREATED_BY, - contentSize=FILE_HANDLE_CONTENT_SIZE, - contentType=FILE_HANDLE_CONTENT_TYPE, - modifiedBy=MODIFIED_BY, - parentId=PARENT_ID, - versionNumber=1, - versionLabel=VERSION_LABEL, - versionComment=VERSION_COMMENT, - dataFileHandleId=DATA_FILE_HANDLE_ID, - ) - - def get_example_synapse_file_output(self, path: str = PATH) -> Synapse_File: - return Synapse_File( - id=SYN_123, - name=FILE_NAME, - path=path, - description=DESCRIPTION, - etag=ETAG, - createdOn=CREATED_ON, - modifiedOn=MODIFIED_ON, - createdBy=CREATED_BY, - modifiedBy=MODIFIED_BY, - parentId=PARENT_ID, - versionNumber=1, - versionLabel=VERSION_LABEL, - versionComment=VERSION_COMMENT, - dataFileHandleId=DATA_FILE_HANDLE_ID, - _file_handle=self.get_example_synapse_file_handle(), - ) - - def get_example_rest_api_file_output( - self, path: str = PATH - ) -> Dict[str, Union[str, int]]: - return { - "entity": { - "concreteType": concrete_types.FILE_ENTITY, - "id": SYN_123, - "name": FILE_NAME, - "path": path, - "description": DESCRIPTION, - "etag": ETAG, - "createdOn": CREATED_ON, - "modifiedOn": MODIFIED_ON, - "createdBy": CREATED_BY, - "modifiedBy": MODIFIED_BY, - "parentId": PARENT_ID, - "versionNumber": 1, - "versionLabel": VERSION_LABEL, - "versionComment": VERSION_COMMENT, - "dataFileHandleId": DATA_FILE_HANDLE_ID, - }, - "fileHandles": [self.get_example_synapse_file_handle()], - } - - def get_example_synapse_file_handle(self) -> Dict[str, Union[str, int, bool]]: - return { - "id": FILE_HANDLE_ID, - "etag": FILE_HANDLE_ETAG, - "createdBy": FILE_HANDLE_CREATED_BY, - "createdOn": FILE_HANDLE_CREATED_ON, - "modifiedOn": FILE_HANDLE_MODIFIED_ON, - "concreteType": FILE_HANDLE_CONCRETE_TYPE, - "contentType": FILE_HANDLE_CONTENT_TYPE, - "contentMd5": FILE_HANDLE_CONTENT_MD5, - "fileName": FILE_HANDLE_FILE_NAME, - "storageLocationId": FILE_HANDLE_STORAGE_LOCATION_ID, - "contentSize": FILE_HANDLE_CONTENT_SIZE, - "status": FILE_HANDLE_STATUS, - "bucketName": FILE_HANDLE_BUCKET_NAME, - "key": FILE_HANDLE_KEY, - "previewId": FILE_HANDLE_PREVIEW_ID, - "isPreview": True, - "externalURL": FILE_HANDLE_EXTERNAL_URL, - } - - def test_fill_from_dict(self) -> None: - # GIVEN an example Synapse File `get_example_synapse_file_output` - # WHEN I call `fill_from_dict` with the example Synapse File - file_output = File().fill_from_dict(self.get_example_synapse_file_output()) - - # THEN the File object should be filled with the example Synapse File - assert file_output.id == SYN_123 - assert file_output.name == FILE_NAME - assert file_output.description == DESCRIPTION - assert file_output.etag == ETAG - assert file_output.created_on == CREATED_ON - assert file_output.modified_on == MODIFIED_ON - assert file_output.created_by == CREATED_BY - assert file_output.modified_by == MODIFIED_BY - assert file_output.parent_id == PARENT_ID - assert file_output.version_number == 1 - assert file_output.version_label == VERSION_LABEL - assert file_output.version_comment == VERSION_COMMENT - assert file_output.data_file_handle_id == DATA_FILE_HANDLE_ID - assert file_output.file_handle.id == FILE_HANDLE_ID - assert file_output.file_handle.etag == FILE_HANDLE_ETAG - assert file_output.file_handle.created_by == FILE_HANDLE_CREATED_BY - assert file_output.file_handle.created_on == FILE_HANDLE_CREATED_ON - assert file_output.file_handle.modified_on == FILE_HANDLE_MODIFIED_ON - assert file_output.file_handle.concrete_type == FILE_HANDLE_CONCRETE_TYPE - assert file_output.file_handle.content_type == FILE_HANDLE_CONTENT_TYPE - assert file_output.file_handle.content_md5 == FILE_HANDLE_CONTENT_MD5 - assert file_output.file_handle.file_name == FILE_HANDLE_FILE_NAME - assert ( - file_output.file_handle.storage_location_id - == FILE_HANDLE_STORAGE_LOCATION_ID - ) - assert file_output.file_handle.content_size == FILE_HANDLE_CONTENT_SIZE - assert file_output.file_handle.status == FILE_HANDLE_STATUS - assert file_output.file_handle.bucket_name == FILE_HANDLE_BUCKET_NAME - assert file_output.file_handle.key == FILE_HANDLE_KEY - assert file_output.file_handle.preview_id == FILE_HANDLE_PREVIEW_ID - assert file_output.file_handle.is_preview - assert file_output.file_handle.external_url == FILE_HANDLE_EXTERNAL_URL - - def test_store_with_id_and_path(self) -> None: - # GIVEN an example file - file = File(id=SYN_123, path=PATH, description=MODIFIED_DESCRIPTION) - - # WHEN I store the example file - with patch( - "synapseclient.api.entity_factory.get_entity_id_bundle2", - new_callable=AsyncMock, - return_value=(self.get_example_rest_api_file_output()), - ) as mocked_get_entity_bundle, patch( - "synapseclient.models.file.upload_file_handle", - new_callable=AsyncMock, - return_value=(self.get_example_synapse_file_handle()), - ) as mocked_file_handle_upload, patch( - "synapseclient.models.file.store_entity", - new_callable=AsyncMock, - return_value=(self.get_example_synapse_file_output()), - ) as mocked_store_entity: - result = file.store(synapse_client=self.syn) - - # THEN we should call the method with this data - mocked_get_entity_bundle.assert_called_once_with( - entity_id=SYN_123, synapse_client=self.syn - ) - - # AND We should upload the file handle - mocked_file_handle_upload.assert_called_once_with( - syn=self.syn, - parent_entity_id=PARENT_ID, - path=PATH, - synapse_store=True, - md5=None, - file_size=FILE_HANDLE_CONTENT_SIZE, - mimetype=FILE_HANDLE_CONTENT_TYPE, - ) - - # AND We should store the entity - mocked_store_entity.assert_called_once() - - # THEN the file should be stored - assert result.id == SYN_123 - assert result.name == FILE_NAME - assert utils.equal_paths(result.path, PATH) - assert result.description == DESCRIPTION - assert result.etag == ETAG - assert result.created_on == CREATED_ON - assert result.modified_on == MODIFIED_ON - assert result.created_by == CREATED_BY - assert result.modified_by == MODIFIED_BY - assert result.parent_id == PARENT_ID - assert result.version_number == 1 - assert result.version_label == VERSION_LABEL - assert result.version_comment == VERSION_COMMENT - assert result.data_file_handle_id == DATA_FILE_HANDLE_ID - assert result.file_handle.id == FILE_HANDLE_ID - assert result.file_handle.etag == FILE_HANDLE_ETAG - assert result.file_handle.created_by == FILE_HANDLE_CREATED_BY - assert result.file_handle.created_on == FILE_HANDLE_CREATED_ON - assert result.file_handle.modified_on == FILE_HANDLE_MODIFIED_ON - assert result.file_handle.concrete_type == FILE_HANDLE_CONCRETE_TYPE - assert result.file_handle.content_type == FILE_HANDLE_CONTENT_TYPE - assert result.file_handle.content_md5 == FILE_HANDLE_CONTENT_MD5 - assert result.file_handle.file_name == FILE_HANDLE_FILE_NAME - assert ( - result.file_handle.storage_location_id - == FILE_HANDLE_STORAGE_LOCATION_ID - ) - assert result.file_handle.content_size == FILE_HANDLE_CONTENT_SIZE - assert result.file_handle.status == FILE_HANDLE_STATUS - assert result.file_handle.bucket_name == FILE_HANDLE_BUCKET_NAME - assert result.file_handle.key == FILE_HANDLE_KEY - assert result.file_handle.preview_id == FILE_HANDLE_PREVIEW_ID - assert result.file_handle.is_preview - assert result.file_handle.external_url == FILE_HANDLE_EXTERNAL_URL - - def test_store_with_id_and_file_handle(self) -> None: - # GIVEN an example file - file = File( - id=SYN_123, - data_file_handle_id=DATA_FILE_HANDLE_ID, - description=MODIFIED_DESCRIPTION, - ) - - # WHEN I store the example file - with patch( - "synapseclient.api.entity_factory.get_entity_id_bundle2", - new_callable=AsyncMock, - return_value=(self.get_example_rest_api_file_output(path=None)), - ) as mocked_get_entity_bundle, patch( - "synapseclient.models.file.upload_file_handle", - new_callable=AsyncMock, - return_value=(self.get_example_synapse_file_handle()), - ) as mocked_file_handle_upload, patch( - "synapseclient.models.file.store_entity", - new_callable=AsyncMock, - return_value=(self.get_example_synapse_file_output()), - ) as mocked_store_entity: - result = file.store(synapse_client=self.syn) - - # THEN we should call the method with this data - mocked_get_entity_bundle.assert_called_once_with( - entity_id=SYN_123, synapse_client=self.syn - ) - - # AND We should not upload the file handle - mocked_file_handle_upload.assert_not_called() - - # AND We should store the entity - mocked_store_entity.assert_called_once() - - # THEN the file should be stored - assert result.id == SYN_123 - assert result.name == FILE_NAME - assert result.description == DESCRIPTION - assert result.etag == ETAG - assert result.created_on == CREATED_ON - assert result.modified_on == MODIFIED_ON - assert result.created_by == CREATED_BY - assert result.modified_by == MODIFIED_BY - assert result.parent_id == PARENT_ID - assert result.version_number == 1 - assert result.version_label == VERSION_LABEL - assert result.version_comment == VERSION_COMMENT - assert result.data_file_handle_id == DATA_FILE_HANDLE_ID - assert result.file_handle.id == FILE_HANDLE_ID - assert result.file_handle.etag == FILE_HANDLE_ETAG - assert result.file_handle.created_by == FILE_HANDLE_CREATED_BY - assert result.file_handle.created_on == FILE_HANDLE_CREATED_ON - assert result.file_handle.modified_on == FILE_HANDLE_MODIFIED_ON - assert result.file_handle.concrete_type == FILE_HANDLE_CONCRETE_TYPE - assert result.file_handle.content_type == FILE_HANDLE_CONTENT_TYPE - assert result.file_handle.content_md5 == FILE_HANDLE_CONTENT_MD5 - assert result.file_handle.file_name == FILE_HANDLE_FILE_NAME - assert ( - result.file_handle.storage_location_id - == FILE_HANDLE_STORAGE_LOCATION_ID - ) - assert result.file_handle.content_size == FILE_HANDLE_CONTENT_SIZE - assert result.file_handle.status == FILE_HANDLE_STATUS - assert result.file_handle.bucket_name == FILE_HANDLE_BUCKET_NAME - assert result.file_handle.key == FILE_HANDLE_KEY - assert result.file_handle.preview_id == FILE_HANDLE_PREVIEW_ID - assert result.file_handle.is_preview - assert result.file_handle.external_url == FILE_HANDLE_EXTERNAL_URL - - def test_store_with_parent_and_path(self) -> None: - # GIVEN An actual file - bogus_file = utils.make_bogus_uuid_file() - - # AND a file object - file = File( - path=bogus_file, - description=MODIFIED_DESCRIPTION, - content_size=os.path.getsize(bogus_file), - content_type="text/plain", - ) - - # WHEN I store the example file - with patch.object( - self.syn, - "get", - return_value=None, - ) as mocked_get_call, patch( - "synapseclient.models.file.get_id", - new_callable=AsyncMock, - return_value=None, - ), patch( - "synapseclient.models.file.upload_file_handle", - new_callable=AsyncMock, - return_value=(self.get_example_synapse_file_handle()), - ) as mocked_file_handle_upload, patch( - "synapseclient.models.file.store_entity", - new_callable=AsyncMock, - return_value=(self.get_example_synapse_file_output(path=bogus_file)), - ) as mocked_store_entity: - result = file.store( - parent=Project(id=ACTUAL_PARENT_ID), synapse_client=self.syn - ) - - # THEN we should not call the get method when just the path is supplied. - mocked_get_call.assert_not_called() - - # AND We should upload the file handle - mocked_file_handle_upload.assert_called_once_with( - syn=self.syn, - parent_entity_id=ACTUAL_PARENT_ID, - path=bogus_file, - synapse_store=True, - md5=None, - file_size=os.path.getsize(bogus_file), - mimetype="text/plain", - ) - - # AND We should store the entity - mocked_store_entity.assert_called_once() - - # THEN the file should be stored - assert result.id == SYN_123 - assert result.name == FILE_NAME - assert utils.equal_paths(result.path, bogus_file) - assert result.description == DESCRIPTION - assert result.etag == ETAG - assert result.created_on == CREATED_ON - assert result.modified_on == MODIFIED_ON - assert result.created_by == CREATED_BY - assert result.modified_by == MODIFIED_BY - assert result.parent_id == PARENT_ID - assert result.version_number == 1 - assert result.version_label == VERSION_LABEL - assert result.version_comment == VERSION_COMMENT - assert result.data_file_handle_id == DATA_FILE_HANDLE_ID - assert result.file_handle.id == FILE_HANDLE_ID - assert result.file_handle.etag == FILE_HANDLE_ETAG - assert result.file_handle.created_by == FILE_HANDLE_CREATED_BY - assert result.file_handle.created_on == FILE_HANDLE_CREATED_ON - assert result.file_handle.modified_on == FILE_HANDLE_MODIFIED_ON - assert result.file_handle.concrete_type == FILE_HANDLE_CONCRETE_TYPE - assert result.file_handle.content_type == FILE_HANDLE_CONTENT_TYPE - assert result.file_handle.content_md5 == FILE_HANDLE_CONTENT_MD5 - assert result.file_handle.file_name == FILE_HANDLE_FILE_NAME - assert ( - result.file_handle.storage_location_id - == FILE_HANDLE_STORAGE_LOCATION_ID - ) - assert result.file_handle.content_size == FILE_HANDLE_CONTENT_SIZE - assert result.file_handle.status == FILE_HANDLE_STATUS - assert result.file_handle.bucket_name == FILE_HANDLE_BUCKET_NAME - assert result.file_handle.key == FILE_HANDLE_KEY - assert result.file_handle.preview_id == FILE_HANDLE_PREVIEW_ID - assert result.file_handle.is_preview - assert result.file_handle.external_url == FILE_HANDLE_EXTERNAL_URL - - def test_store_with_parent_id_and_path(self) -> None: - # GIVEN An actual file - bogus_file = utils.make_bogus_uuid_file() - - # AND a file object - file = File( - path=bogus_file, - parent_id=ACTUAL_PARENT_ID, - description=MODIFIED_DESCRIPTION, - content_size=os.path.getsize(bogus_file), - content_type="text/plain", - ) - - # WHEN I store the example file - with patch.object( - self.syn, - "get", - return_value=None, - ) as mocked_get_call, patch( - "synapseclient.models.file.get_id", - new_callable=AsyncMock, - return_value=None, - ), patch( - "synapseclient.models.file.upload_file_handle", - new_callable=AsyncMock, - return_value=(self.get_example_synapse_file_handle()), - ) as mocked_file_handle_upload, patch( - "synapseclient.models.file.store_entity", - new_callable=AsyncMock, - return_value=(self.get_example_synapse_file_output(path=bogus_file)), - ) as mocked_store_entity: - result = file.store( - parent=Project(id=ACTUAL_PARENT_ID), synapse_client=self.syn - ) - - # THEN we should not call the get method when just the path is supplied. - mocked_get_call.assert_not_called() - - # AND We should upload the file handle - mocked_file_handle_upload.assert_called_once_with( - syn=self.syn, - parent_entity_id=ACTUAL_PARENT_ID, - path=bogus_file, - synapse_store=True, - md5=None, - file_size=os.path.getsize(bogus_file), - mimetype="text/plain", - ) - - # AND We should store the entity - mocked_store_entity.assert_called_once() - - # THEN the file should be stored - assert result.id == SYN_123 - assert result.name == FILE_NAME - assert utils.equal_paths(result.path, bogus_file) - assert result.description == DESCRIPTION - assert result.etag == ETAG - assert result.created_on == CREATED_ON - assert result.modified_on == MODIFIED_ON - assert result.created_by == CREATED_BY - assert result.modified_by == MODIFIED_BY - assert result.parent_id == PARENT_ID - assert result.version_number == 1 - assert result.version_label == VERSION_LABEL - assert result.version_comment == VERSION_COMMENT - assert result.data_file_handle_id == DATA_FILE_HANDLE_ID - assert result.file_handle.id == FILE_HANDLE_ID - assert result.file_handle.etag == FILE_HANDLE_ETAG - assert result.file_handle.created_by == FILE_HANDLE_CREATED_BY - assert result.file_handle.created_on == FILE_HANDLE_CREATED_ON - assert result.file_handle.modified_on == FILE_HANDLE_MODIFIED_ON - assert result.file_handle.concrete_type == FILE_HANDLE_CONCRETE_TYPE - assert result.file_handle.content_type == FILE_HANDLE_CONTENT_TYPE - assert result.file_handle.content_md5 == FILE_HANDLE_CONTENT_MD5 - assert result.file_handle.file_name == FILE_HANDLE_FILE_NAME - assert ( - result.file_handle.storage_location_id - == FILE_HANDLE_STORAGE_LOCATION_ID - ) - assert result.file_handle.content_size == FILE_HANDLE_CONTENT_SIZE - assert result.file_handle.status == FILE_HANDLE_STATUS - assert result.file_handle.bucket_name == FILE_HANDLE_BUCKET_NAME - assert result.file_handle.key == FILE_HANDLE_KEY - assert result.file_handle.preview_id == FILE_HANDLE_PREVIEW_ID - assert result.file_handle.is_preview - assert result.file_handle.external_url == FILE_HANDLE_EXTERNAL_URL - - def test_store_with_components(self) -> None: - # GIVEN An actual file - bogus_file = utils.make_bogus_uuid_file() - - # AND a file object - file = File( - path=bogus_file, - parent_id=ACTUAL_PARENT_ID, - annotations={"key": "value"}, - activity=Activity( - name="My Activity", - executed=[ - UsedURL(name="Used URL", url="https://www.synapse.org/"), - ], - ), - description=MODIFIED_DESCRIPTION, - content_size=os.path.getsize(bogus_file), - content_type="text/plain", - ) - - # WHEN I store the example file - with patch.object( - self.syn, - "get", - return_value=None, - ) as mocked_get_call, patch( - "synapseclient.models.file.get_id", - new_callable=AsyncMock, - return_value=None, - ), patch( - "synapseclient.models.file.upload_file_handle", - new_callable=AsyncMock, - return_value=(self.get_example_synapse_file_handle()), - ) as mocked_file_handle_upload, patch( - "synapseclient.models.file.store_entity", - new_callable=AsyncMock, - return_value=(self.get_example_synapse_file_output(path=bogus_file)), - ) as mocked_store_entity, patch( - "synapseclient.models.file.store_entity_components", - return_value=True, - ) as mocked_store_entity_components, patch.object( - file, - "get_async", - return_value=file, - ) as mocked_get: - result = file.store(synapse_client=self.syn) - - # THEN we should not call the get method when just the path is supplied. - mocked_get_call.assert_not_called() - - # AND We should upload the file handle - mocked_file_handle_upload.assert_called_once_with( - syn=self.syn, - parent_entity_id=ACTUAL_PARENT_ID, - path=bogus_file, - synapse_store=True, - md5=None, - file_size=os.path.getsize(bogus_file), - mimetype="text/plain", - ) - - # AND We should store the entity - mocked_store_entity.assert_called_once() - - # AND we should store the components - mocked_store_entity_components.assert_called_once() - - # AND we should get the file - mocked_get.assert_called_once() - - # THEN the file should be stored - assert result.id == SYN_123 - assert result.name == FILE_NAME - assert utils.equal_paths(result.path, bogus_file) - assert result.description == DESCRIPTION - assert result.etag == ETAG - assert result.created_on == CREATED_ON - assert result.modified_on == MODIFIED_ON - assert result.created_by == CREATED_BY - assert result.modified_by == MODIFIED_BY - assert result.parent_id == PARENT_ID - assert result.version_number == 1 - assert result.version_label == VERSION_LABEL - assert result.version_comment == VERSION_COMMENT - assert result.data_file_handle_id == DATA_FILE_HANDLE_ID - assert result.file_handle.id == FILE_HANDLE_ID - assert result.file_handle.etag == FILE_HANDLE_ETAG - assert result.file_handle.created_by == FILE_HANDLE_CREATED_BY - assert result.file_handle.created_on == FILE_HANDLE_CREATED_ON - assert result.file_handle.modified_on == FILE_HANDLE_MODIFIED_ON - assert result.file_handle.concrete_type == FILE_HANDLE_CONCRETE_TYPE - assert result.file_handle.content_type == FILE_HANDLE_CONTENT_TYPE - assert result.file_handle.content_md5 == FILE_HANDLE_CONTENT_MD5 - assert result.file_handle.file_name == FILE_HANDLE_FILE_NAME - assert ( - result.file_handle.storage_location_id - == FILE_HANDLE_STORAGE_LOCATION_ID - ) - assert result.file_handle.content_size == FILE_HANDLE_CONTENT_SIZE - assert result.file_handle.status == FILE_HANDLE_STATUS - assert result.file_handle.bucket_name == FILE_HANDLE_BUCKET_NAME - assert result.file_handle.key == FILE_HANDLE_KEY - assert result.file_handle.preview_id == FILE_HANDLE_PREVIEW_ID - assert result.file_handle.is_preview - assert result.file_handle.external_url == FILE_HANDLE_EXTERNAL_URL - - def test_store_id_with_no_path_or_file_handle(self) -> None: - # GIVEN an example file - file = File(id=SYN_123, description=MODIFIED_DESCRIPTION) - - # WHEN I get the file - with pytest.raises(ValueError) as e: - file.store(synapse_client=self.syn) - - # THEN we should get an error - assert str(e.value) == CANNOT_STORE_FILE_ERROR - - def test_store_path_with_no_id(self) -> None: - # GIVEN an example file - file = File(path=PATH, description=MODIFIED_DESCRIPTION) - - # WHEN I get the file - with pytest.raises(ValueError) as e: - file.store(synapse_client=self.syn) - - # THEN we should get an error - assert str(e.value) == CANNOT_STORE_FILE_ERROR - - def test_store_id_with_parent_id(self) -> None: - # GIVEN an example file - file = File( - id=SYN_123, parent_id=ACTUAL_PARENT_ID, description=MODIFIED_DESCRIPTION - ) - - # WHEN I get the file - with pytest.raises(ValueError) as e: - file.store(synapse_client=self.syn) - - # THEN we should get an error - assert str(e.value) == CANNOT_STORE_FILE_ERROR - - def test_store_id_with_parent(self) -> None: - # GIVEN an example file - file = File(id=SYN_123, description=MODIFIED_DESCRIPTION) - - # WHEN I get the file - with pytest.raises(ValueError) as e: - file.store(parent=Project(id=ACTUAL_PARENT_ID), synapse_client=self.syn) - - # THEN we should get an error - assert str(e.value) == CANNOT_STORE_FILE_ERROR - - def test_change_file_metadata(self) -> None: - # GIVEN an example file - file = File(id=SYN_123, description=MODIFIED_DESCRIPTION) - - # WHEN I change the metadata on the example file - with patch( - "synapseutils.copy_functions.changeFileMetaData", - return_value=(self.get_example_synapse_file_output()), - ) as mocked_change_meta_data: - result = file.change_metadata( - name="modified_file.txt", - download_as="modified_file.txt", - content_type="text/plain", - synapse_client=self.syn, - ) - - # THEN we should call the method with this data - mocked_change_meta_data.assert_called_once_with( - syn=self.syn, - entity=SYN_123, - name="modified_file.txt", - downloadAs="modified_file.txt", - contentType="text/plain", - forceVersion=True, - ) - - # THEN the file should be updated with the mock return - assert result.id == SYN_123 - assert result.name == FILE_NAME - assert result.description == DESCRIPTION - assert result.etag == ETAG - assert result.created_on == CREATED_ON - assert result.modified_on == MODIFIED_ON - assert result.created_by == CREATED_BY - assert result.modified_by == MODIFIED_BY - assert result.parent_id == PARENT_ID - assert result.version_number == 1 - assert result.version_label == VERSION_LABEL - assert result.version_comment == VERSION_COMMENT - assert result.data_file_handle_id == DATA_FILE_HANDLE_ID - assert result.file_handle.id == FILE_HANDLE_ID - assert result.file_handle.etag == FILE_HANDLE_ETAG - assert result.file_handle.created_by == FILE_HANDLE_CREATED_BY - assert result.file_handle.created_on == FILE_HANDLE_CREATED_ON - assert result.file_handle.modified_on == FILE_HANDLE_MODIFIED_ON - assert result.file_handle.concrete_type == FILE_HANDLE_CONCRETE_TYPE - assert result.file_handle.content_type == FILE_HANDLE_CONTENT_TYPE - assert result.file_handle.content_md5 == FILE_HANDLE_CONTENT_MD5 - assert result.file_handle.file_name == FILE_HANDLE_FILE_NAME - assert ( - result.file_handle.storage_location_id - == FILE_HANDLE_STORAGE_LOCATION_ID - ) - assert result.file_handle.content_size == FILE_HANDLE_CONTENT_SIZE - assert result.file_handle.status == FILE_HANDLE_STATUS - assert result.file_handle.bucket_name == FILE_HANDLE_BUCKET_NAME - assert result.file_handle.key == FILE_HANDLE_KEY - assert result.file_handle.preview_id == FILE_HANDLE_PREVIEW_ID - assert result.file_handle.is_preview - assert result.file_handle.external_url == FILE_HANDLE_EXTERNAL_URL - - def test_change_file_metadata_missing_id(self) -> None: - # GIVEN an example file - file = File(description=MODIFIED_DESCRIPTION) - - # WHEN I change the metadata on the example file - with pytest.raises(ValueError) as e: - file.change_metadata(synapse_client=self.syn) - - # THEN we should get an error - assert str(e.value) == "The file must have an ID to change metadata." - - def test_get_with_id(self) -> None: - # GIVEN an example file - file = File(id=SYN_123, description=MODIFIED_DESCRIPTION) - - # AND An actual file - bogus_file = utils.make_bogus_uuid_file() - - # AND a local file cache - self.syn.cache.add( - file_handle_id=DATA_FILE_HANDLE_ID, - path=bogus_file, - ) - - # WHEN I get the example file - with patch( - "synapseclient.api.entity_factory.get_entity_id_bundle2", - new_callable=AsyncMock, - return_value=(self.get_example_rest_api_file_output(path=bogus_file)), - ) as mocked_get_entity_bundle: - result = file.get(synapse_client=self.syn) - os.remove(bogus_file) - - # THEN we should call the method with this data - mocked_get_entity_bundle.assert_called_once_with( - entity_id=SYN_123, synapse_client=self.syn - ) - - # THEN the file should be retrieved - assert result.id == SYN_123 - assert result.name == FILE_NAME - assert utils.equal_paths(result.path, bogus_file) - assert result.description == DESCRIPTION - assert result.etag == ETAG - assert result.created_on == CREATED_ON - assert result.modified_on == MODIFIED_ON - assert result.created_by == CREATED_BY - assert result.modified_by == MODIFIED_BY - assert result.parent_id == PARENT_ID - assert result.version_number == 1 - assert result.version_label == VERSION_LABEL - assert result.version_comment == VERSION_COMMENT - assert result.data_file_handle_id == DATA_FILE_HANDLE_ID - assert result.file_handle.id == FILE_HANDLE_ID - assert result.file_handle.etag == FILE_HANDLE_ETAG - assert result.file_handle.created_by == FILE_HANDLE_CREATED_BY - assert result.file_handle.created_on == FILE_HANDLE_CREATED_ON - assert result.file_handle.modified_on == FILE_HANDLE_MODIFIED_ON - assert result.file_handle.concrete_type == FILE_HANDLE_CONCRETE_TYPE - assert result.file_handle.content_type == FILE_HANDLE_CONTENT_TYPE - assert result.file_handle.content_md5 == FILE_HANDLE_CONTENT_MD5 - assert result.file_handle.file_name == FILE_HANDLE_FILE_NAME - assert ( - result.file_handle.storage_location_id - == FILE_HANDLE_STORAGE_LOCATION_ID - ) - assert result.file_handle.content_size == FILE_HANDLE_CONTENT_SIZE - assert result.file_handle.status == FILE_HANDLE_STATUS - assert result.file_handle.bucket_name == FILE_HANDLE_BUCKET_NAME - assert result.file_handle.key == FILE_HANDLE_KEY - assert result.file_handle.preview_id == FILE_HANDLE_PREVIEW_ID - assert result.file_handle.is_preview - assert result.file_handle.external_url == FILE_HANDLE_EXTERNAL_URL - - def test_get_with_path(self) -> None: - # GIVEN an example file - file = File(path=PATH, description=MODIFIED_DESCRIPTION) - - # WHEN I get the example file - with patch( - "synapseclient.api.entity_factory._search_for_file_by_md5", - new_callable=AsyncMock, - return_value=(self.get_example_rest_api_file_output()), - ) as mocked_search_for_file, patch.object( - file, - "_load_local_md5", - return_value=(None), - ), patch( - "os.path.isfile", return_value=True - ): - result = file.get(synapse_client=self.syn) - - # THEN we should call the method with this data - mocked_search_for_file.assert_called_once_with( - filepath="/asdf/example_file.txt", - limit_search=None, - md5=None, - synapse_client=self.syn, - ) - - # THEN the file should be stored - assert result.id == SYN_123 - assert result.name == FILE_NAME - assert utils.equal_paths(result.path, PATH) - assert result.description == DESCRIPTION - assert result.etag == ETAG - assert result.created_on == CREATED_ON - assert result.modified_on == MODIFIED_ON - assert result.created_by == CREATED_BY - assert result.modified_by == MODIFIED_BY - assert result.parent_id == PARENT_ID - assert result.version_number == 1 - assert result.version_label == VERSION_LABEL - assert result.version_comment == VERSION_COMMENT - assert result.data_file_handle_id == DATA_FILE_HANDLE_ID - assert result.file_handle.id == FILE_HANDLE_ID - assert result.file_handle.etag == FILE_HANDLE_ETAG - assert result.file_handle.created_by == FILE_HANDLE_CREATED_BY - assert result.file_handle.created_on == FILE_HANDLE_CREATED_ON - assert result.file_handle.modified_on == FILE_HANDLE_MODIFIED_ON - assert result.file_handle.concrete_type == FILE_HANDLE_CONCRETE_TYPE - assert result.file_handle.content_type == FILE_HANDLE_CONTENT_TYPE - assert result.file_handle.content_md5 == FILE_HANDLE_CONTENT_MD5 - assert result.file_handle.file_name == FILE_HANDLE_FILE_NAME - assert ( - result.file_handle.storage_location_id - == FILE_HANDLE_STORAGE_LOCATION_ID - ) - assert result.file_handle.content_size == FILE_HANDLE_CONTENT_SIZE - assert result.file_handle.status == FILE_HANDLE_STATUS - assert result.file_handle.bucket_name == FILE_HANDLE_BUCKET_NAME - assert result.file_handle.key == FILE_HANDLE_KEY - assert result.file_handle.preview_id == FILE_HANDLE_PREVIEW_ID - assert result.file_handle.is_preview - assert result.file_handle.external_url == FILE_HANDLE_EXTERNAL_URL - - def test_from_path(self) -> None: - # GIVEN an example path - path = PATH - - # WHEN I get the example file - with patch( - "synapseclient.api.entity_factory._search_for_file_by_md5", - new_callable=AsyncMock, - return_value=(self.get_example_rest_api_file_output()), - ) as mocked_search_for_file, patch( - "synapseclient.models.file.File._load_local_md5", - return_value=(None), - ), patch( - "os.path.isfile", return_value=True - ): - result = File.from_path(path=path, synapse_client=self.syn) - - # THEN we should call the method with this data - mocked_search_for_file.assert_called_once_with( - filepath=PATH, limit_search=None, md5=None, synapse_client=self.syn - ) - - # THEN the file should be retrieved - assert result.id == SYN_123 - assert result.name == FILE_NAME - assert utils.equal_paths(result.path, PATH) - assert result.description == DESCRIPTION - assert result.etag == ETAG - assert result.created_on == CREATED_ON - assert result.modified_on == MODIFIED_ON - assert result.created_by == CREATED_BY - assert result.modified_by == MODIFIED_BY - assert result.parent_id == PARENT_ID - assert result.version_number == 1 - assert result.version_label == VERSION_LABEL - assert result.version_comment == VERSION_COMMENT - assert result.data_file_handle_id == DATA_FILE_HANDLE_ID - assert result.file_handle.id == FILE_HANDLE_ID - assert result.file_handle.etag == FILE_HANDLE_ETAG - assert result.file_handle.created_by == FILE_HANDLE_CREATED_BY - assert result.file_handle.created_on == FILE_HANDLE_CREATED_ON - assert result.file_handle.modified_on == FILE_HANDLE_MODIFIED_ON - assert result.file_handle.concrete_type == FILE_HANDLE_CONCRETE_TYPE - assert result.file_handle.content_type == FILE_HANDLE_CONTENT_TYPE - assert result.file_handle.content_md5 == FILE_HANDLE_CONTENT_MD5 - assert result.file_handle.file_name == FILE_HANDLE_FILE_NAME - assert ( - result.file_handle.storage_location_id - == FILE_HANDLE_STORAGE_LOCATION_ID - ) - assert result.file_handle.content_size == FILE_HANDLE_CONTENT_SIZE - assert result.file_handle.status == FILE_HANDLE_STATUS - assert result.file_handle.bucket_name == FILE_HANDLE_BUCKET_NAME - assert result.file_handle.key == FILE_HANDLE_KEY - assert result.file_handle.preview_id == FILE_HANDLE_PREVIEW_ID - assert result.file_handle.is_preview - assert result.file_handle.external_url == FILE_HANDLE_EXTERNAL_URL - - def test_from_id(self) -> None: - # GIVEN an example id - synapse_id = SYN_123 - - # AND An actual file - bogus_file = utils.make_bogus_uuid_file() - - # AND a local file cache - self.syn.cache.add( - file_handle_id=DATA_FILE_HANDLE_ID, - path=bogus_file, - ) - - # WHEN I get the example file - with patch( - "synapseclient.api.entity_factory.get_entity_id_bundle2", - new_callable=AsyncMock, - return_value=(self.get_example_rest_api_file_output(path=bogus_file)), - ) as mocked_get_entity_bundle: - result = File.from_id(synapse_id=synapse_id, synapse_client=self.syn) - os.remove(bogus_file) - - # THEN we should call the method with this data - mocked_get_entity_bundle.assert_called_once_with( - entity_id=SYN_123, synapse_client=self.syn - ) - - # THEN the file should be retrieved - assert result.id == SYN_123 - assert result.name == FILE_NAME - assert utils.equal_paths(result.path, bogus_file) - assert result.description == DESCRIPTION - assert result.etag == ETAG - assert result.created_on == CREATED_ON - assert result.modified_on == MODIFIED_ON - assert result.created_by == CREATED_BY - assert result.modified_by == MODIFIED_BY - assert result.parent_id == PARENT_ID - assert result.version_number == 1 - assert result.version_label == VERSION_LABEL - assert result.version_comment == VERSION_COMMENT - assert result.data_file_handle_id == DATA_FILE_HANDLE_ID - assert result.file_handle.id == FILE_HANDLE_ID - assert result.file_handle.etag == FILE_HANDLE_ETAG - assert result.file_handle.created_by == FILE_HANDLE_CREATED_BY - assert result.file_handle.created_on == FILE_HANDLE_CREATED_ON - assert result.file_handle.modified_on == FILE_HANDLE_MODIFIED_ON - assert result.file_handle.concrete_type == FILE_HANDLE_CONCRETE_TYPE - assert result.file_handle.content_type == FILE_HANDLE_CONTENT_TYPE - assert result.file_handle.content_md5 == FILE_HANDLE_CONTENT_MD5 - assert result.file_handle.file_name == FILE_HANDLE_FILE_NAME - assert ( - result.file_handle.storage_location_id - == FILE_HANDLE_STORAGE_LOCATION_ID - ) - assert result.file_handle.content_size == FILE_HANDLE_CONTENT_SIZE - assert result.file_handle.status == FILE_HANDLE_STATUS - assert result.file_handle.bucket_name == FILE_HANDLE_BUCKET_NAME - assert result.file_handle.key == FILE_HANDLE_KEY - assert result.file_handle.preview_id == FILE_HANDLE_PREVIEW_ID - assert result.file_handle.is_preview - assert result.file_handle.external_url == FILE_HANDLE_EXTERNAL_URL - - def test_get_missing_id_and_path(self) -> None: - # GIVEN an example file - file = File(description=MODIFIED_DESCRIPTION) - - # WHEN I get the file - with pytest.raises(ValueError) as e: - file.get(synapse_client=self.syn) - - # THEN we should get an error - assert str(e.value) == "The file must have an ID or path to get." - - def test_delete(self) -> None: - # GIVEN an example file - file = File(id=SYN_123, description=MODIFIED_DESCRIPTION) - - # WHEN I delete the example file - with patch.object( - self.syn, - "delete", - return_value=(None), - ) as mocked_client_call: - file.delete(synapse_client=self.syn) - - # THEN we should call the method with this data - mocked_client_call.assert_called_once_with( - obj=SYN_123, - version=None, - ) - - def test_delete_missing_id(self) -> None: - # GIVEN an example file - file = File(description=MODIFIED_DESCRIPTION) - - # WHEN I delete the file - with pytest.raises(ValueError) as e: - file.delete(synapse_client=self.syn) - - # THEN we should get an error - assert str(e.value) == "The file must have an ID to delete." - - def test_delete_version_missing_version(self) -> None: - # GIVEN an example file - file = File(id="syn123", description=MODIFIED_DESCRIPTION) - - # WHEN I delete the file - with pytest.raises(ValueError) as e: - file.delete(version_only=True, synapse_client=self.syn) - - # THEN we should get an error - assert ( - str(e.value) == "The file must have a version number to delete a version." - ) diff --git a/tests/unit/synapseclient/models/synchronous/unit_test_folder.py b/tests/unit/synapseclient/models/synchronous/unit_test_folder.py deleted file mode 100644 index bcefaf545..000000000 --- a/tests/unit/synapseclient/models/synchronous/unit_test_folder.py +++ /dev/null @@ -1,780 +0,0 @@ -"""Tests for the Folder class.""" -import uuid -from typing import Dict -from unittest.mock import AsyncMock, patch - -import pytest - -from synapseclient import Folder as Synapse_Folder -from synapseclient import Synapse -from synapseclient.core.constants import concrete_types -from synapseclient.core.constants.concrete_types import FILE_ENTITY -from synapseclient.core.exceptions import SynapseNotFoundError -from synapseclient.models import FailureStrategy, File, Folder - -SYN_123 = "syn123" -SYN_456 = "syn456" -FOLDER_NAME = "example_folder" -PARENT_ID = "parent_id_value" -DESCRIPTION = "This is an example folder." -ETAG = "etag_value" -CREATED_ON = "createdOn_value" -MODIFIED_ON = "modifiedOn_value" -CREATED_BY = "createdBy_value" -MODIFIED_BY = "modifiedBy_value" - - -class TestFolder: - """Tests for the Folder class.""" - - @pytest.fixture(autouse=True, scope="function") - def init_syn(self, syn: Synapse) -> None: - self.syn = syn - - def get_example_synapse_folder_output(self) -> Synapse_Folder: - return Synapse_Folder( - id=SYN_123, - name=FOLDER_NAME, - parentId=PARENT_ID, - description=DESCRIPTION, - etag=ETAG, - createdOn=CREATED_ON, - modifiedOn=MODIFIED_ON, - createdBy=CREATED_BY, - modifiedBy=MODIFIED_BY, - ) - - def get_example_rest_api_folder_output(self) -> Dict[str, str]: - return { - "entity": { - "concreteType": concrete_types.FOLDER_ENTITY, - "id": SYN_123, - "name": FOLDER_NAME, - "parentId": PARENT_ID, - "description": DESCRIPTION, - "etag": ETAG, - "createdOn": CREATED_ON, - "modifiedOn": MODIFIED_ON, - "createdBy": CREATED_BY, - "modifiedBy": MODIFIED_BY, - }, - } - - def test_fill_from_dict(self) -> None: - # GIVEN an example Synapse Folder `get_example_synapse_folder_output` - # WHEN I call `fill_from_dict` with the example Synapse Folder - folder_output = Folder().fill_from_dict( - self.get_example_synapse_folder_output() - ) - - # THEN the Folder object should be filled with the example Synapse Folder - assert folder_output.id == SYN_123 - assert folder_output.name == FOLDER_NAME - assert folder_output.parent_id == PARENT_ID - assert folder_output.description == DESCRIPTION - assert folder_output.etag == ETAG - assert folder_output.created_on == CREATED_ON - assert folder_output.modified_on == MODIFIED_ON - assert folder_output.created_by == CREATED_BY - assert folder_output.modified_by == MODIFIED_BY - - def test_store_with_id(self) -> None: - # GIVEN a Folder object - folder = Folder( - id=SYN_123, - ) - - # AND a random description - description = str(uuid.uuid4()) - folder.description = description - - # WHEN I call `store` with the Folder object - with patch( - "synapseclient.models.services.storable_entity.put_entity", - new_callable=AsyncMock, - return_value=(self.get_example_synapse_folder_output()), - ) as mocked_client_call, patch( - "synapseclient.api.entity_factory.get_entity_id_bundle2", - new_callable=AsyncMock, - return_value=( - { - "entity": { - "concreteType": concrete_types.FOLDER_ENTITY, - "id": folder.id, - } - } - ), - ) as mocked_get: - result = folder.store(synapse_client=self.syn) - - # THEN we should call the method with this data - mocked_client_call.assert_called_once() - call_args = mocked_client_call.call_args - assert call_args.kwargs["entity_id"] == folder.id - assert call_args.kwargs["new_version"] is False - assert call_args.kwargs["synapse_client"] == self.syn - # The request should be a dict with the folder properties - request_dict = call_args.kwargs["request"] - assert ( - request_dict["concreteType"] == "org.sagebionetworks.repo.model.Folder" - ) - assert request_dict["id"] == folder.id - assert request_dict["description"] == description - - # AND we should call the get method - mocked_get.assert_called_once() - - # AND the folder should be stored with the mock return data - assert result.id == SYN_123 - assert result.name == FOLDER_NAME - assert result.parent_id == PARENT_ID - assert result.description == DESCRIPTION - assert result.etag == ETAG - assert result.created_on == CREATED_ON - assert result.modified_on == MODIFIED_ON - assert result.created_by == CREATED_BY - assert result.modified_by == MODIFIED_BY - - def test_store_with_no_changes(self) -> None: - # GIVEN a Folder object - folder = Folder( - id=SYN_123, - ) - - # WHEN I call `store` with the Folder object - with patch.object( - self.syn, - "store", - ) as mocked_store, patch( - "synapseclient.api.entity_factory.get_entity_id_bundle2", - new_callable=AsyncMock, - return_value=( - { - "entity": { - "concreteType": concrete_types.FOLDER_ENTITY, - "id": folder.id, - } - } - ), - ) as mocked_get: - result = folder.store(synapse_client=self.syn) - - # THEN we should not call store because there are no changes - mocked_store.assert_not_called() - - # AND we should call the get method - mocked_get.assert_called_once() - - # AND the folder should only contain the ID - assert result.id == SYN_123 - - def test_store_after_get(self) -> None: - # GIVEN a Folder object - folder = Folder( - id=SYN_123, - ) - - # AND I call `get` on the Folder object - with patch( - "synapseclient.api.entity_factory.get_entity_id_bundle2", - new_callable=AsyncMock, - return_value=( - { - "entity": { - "concreteType": concrete_types.FOLDER_ENTITY, - "id": folder.id, - } - } - ), - ) as mocked_get: - folder.get(synapse_client=self.syn) - - mocked_get.assert_called_once_with( - entity_id=folder.id, synapse_client=self.syn - ) - assert folder.id == SYN_123 - - # WHEN I call `store` with the Folder object - with patch.object( - self.syn, - "store", - ) as mocked_store, patch.object( - self.syn, - "get", - return_value=Synapse_Folder( - id=folder.id, - ), - ) as mocked_get: - result = folder.store(synapse_client=self.syn) - - # THEN we should not call store because there are no changes - mocked_store.assert_not_called() - - # AND we should not call get as we already have - mocked_get.assert_not_called() - - # AND the folder should only contain the ID - assert result.id == SYN_123 - - def test_store_after_get_with_changes(self) -> None: - # GIVEN a Folder object - folder = Folder( - id=SYN_123, - ) - - # AND I call `get` on the Folder object - with patch( - "synapseclient.api.entity_factory.get_entity_id_bundle2", - new_callable=AsyncMock, - return_value=( - { - "entity": { - "concreteType": concrete_types.FOLDER_ENTITY, - "id": folder.id, - } - } - ), - ) as mocked_get: - folder.get(synapse_client=self.syn) - - mocked_get.assert_called_once_with( - entity_id=folder.id, synapse_client=self.syn - ) - assert folder.id == SYN_123 - - # AND I update a field on the folder - description = str(uuid.uuid4()) - folder.description = description - - # WHEN I call `store` with the Folder object - with patch( - "synapseclient.models.services.storable_entity.put_entity", - new_callable=AsyncMock, - return_value=(self.get_example_synapse_folder_output()), - ) as mocked_store, patch( - "synapseclient.api.entity_factory.get_entity_id_bundle2", - new_callable=AsyncMock, - ) as mocked_get: - result = folder.store(synapse_client=self.syn) - - # THEN we should call store because there are changes - mocked_store.assert_called_once() - call_args = mocked_store.call_args - assert call_args.kwargs["entity_id"] == folder.id - assert call_args.kwargs["new_version"] is False - assert call_args.kwargs["synapse_client"] == self.syn - # The request should be a dict with the folder properties - request_dict = call_args.kwargs["request"] - assert ( - request_dict["concreteType"] == "org.sagebionetworks.repo.model.Folder" - ) - assert request_dict["id"] == folder.id - assert request_dict["description"] == description - - # AND we should not call get as we already have - mocked_get.assert_not_called() - - # AND the folder should contained the mocked store return data - assert result.id == SYN_123 - assert result.name == FOLDER_NAME - assert result.parent_id == PARENT_ID - assert result.description == DESCRIPTION - assert result.etag == ETAG - assert result.created_on == CREATED_ON - assert result.modified_on == MODIFIED_ON - assert result.created_by == CREATED_BY - assert result.modified_by == MODIFIED_BY - - def test_store_with_annotations(self) -> None: - # GIVEN a Folder object - folder = Folder( - id=SYN_123, - annotations={ - "my_single_key_string": ["a"], - "my_key_string": ["b", "a", "c"], - "my_key_bool": [False, False, False], - "my_key_double": [1.2, 3.4, 5.6], - "my_key_long": [1, 2, 3], - }, - ) - - # AND a random description - description = str(uuid.uuid4()) - folder.description = description - - # WHEN I call `store` with the Folder object - with patch( - "synapseclient.models.folder.store_entity_components", - return_value=(None), - ) as mocked_store_entity_components, patch( - "synapseclient.models.services.storable_entity.put_entity", - new_callable=AsyncMock, - return_value=(self.get_example_synapse_folder_output()), - ) as mocked_client_call, patch( - "synapseclient.api.entity_factory.get_entity_id_bundle2", - new_callable=AsyncMock, - return_value=( - { - "entity": { - "concreteType": concrete_types.FOLDER_ENTITY, - "id": folder.id, - } - } - ), - ) as mocked_get: - result = folder.store(synapse_client=self.syn) - - # THEN we should call the method with this data - mocked_client_call.assert_called_once() - call_args = mocked_client_call.call_args - assert call_args.kwargs["entity_id"] == folder.id - assert call_args.kwargs["new_version"] is False - assert call_args.kwargs["synapse_client"] == self.syn - # The request should be a dict with the folder properties - request_dict = call_args.kwargs["request"] - assert ( - request_dict["concreteType"] == "org.sagebionetworks.repo.model.Folder" - ) - assert request_dict["id"] == folder.id - assert request_dict["description"] == description - - # AND we should call the get method - mocked_get.assert_called_once() - - # AND we should store the annotations component - mocked_store_entity_components.assert_called_once_with( - root_resource=folder, - failure_strategy=FailureStrategy.LOG_EXCEPTION, - synapse_client=self.syn, - ) - - # AND the folder should be stored with the mock return data - assert result.id == SYN_123 - assert result.name == FOLDER_NAME - assert result.parent_id == PARENT_ID - assert result.description == DESCRIPTION - assert result.etag == ETAG - assert result.created_on == CREATED_ON - assert result.modified_on == MODIFIED_ON - assert result.created_by == CREATED_BY - assert result.modified_by == MODIFIED_BY - - def test_store_with_name_and_parent_id(self) -> None: - # GIVEN a Folder object - folder = Folder( - name=FOLDER_NAME, - parent_id=PARENT_ID, - ) - - # AND a random description - description = str(uuid.uuid4()) - folder.description = description - - # WHEN I call `store` with the Folder object - with patch( - "synapseclient.models.services.storable_entity.put_entity", - new_callable=AsyncMock, - return_value=(self.get_example_synapse_folder_output()), - ) as mocked_client_call, patch.object( - self.syn, - "findEntityId", - return_value=SYN_123, - ) as mocked_get, patch( - "synapseclient.api.entity_factory.get_entity_id_bundle2", - new_callable=AsyncMock, - return_value=( - { - "entity": { - "concreteType": concrete_types.FOLDER_ENTITY, - "id": folder.id, - } - } - ), - ) as mocked_get: - result = folder.store() - - # THEN we should call the method with this data - mocked_client_call.assert_called_once() - assert mocked_client_call.call_args.kwargs["entity_id"] == SYN_123 - assert mocked_client_call.call_args.kwargs["new_version"] is False - assert mocked_client_call.call_args.kwargs["synapse_client"] is None - request_dict = mocked_client_call.call_args.kwargs["request"] - assert request_dict["id"] == SYN_123 - assert request_dict["name"] == FOLDER_NAME - assert request_dict["parentId"] == PARENT_ID - assert request_dict["description"] == description - assert request_dict["concreteType"] == concrete_types.FOLDER_ENTITY - - # AND we should call the get method - mocked_get.assert_called_once() - - # AND findEntityId should be called - mocked_get.assert_called_once() - - # AND the folder should be stored - assert result.id == SYN_123 - assert result.name == FOLDER_NAME - assert result.parent_id == PARENT_ID - assert result.description == DESCRIPTION - assert result.etag == ETAG - assert result.created_on == CREATED_ON - assert result.modified_on == MODIFIED_ON - assert result.created_by == CREATED_BY - assert result.modified_by == MODIFIED_BY - - def test_store_with_name_and_parent(self) -> None: - # GIVEN a Folder object - folder = Folder( - name=FOLDER_NAME, - ) - - # AND a random description - description = str(uuid.uuid4()) - folder.description = description - - # WHEN I call `store` with the Folder object - with patch( - "synapseclient.models.services.storable_entity.put_entity", - new_callable=AsyncMock, - return_value=(self.get_example_synapse_folder_output()), - ) as mocked_client_call, patch.object( - self.syn, - "findEntityId", - return_value=SYN_123, - ) as mocked_get, patch( - "synapseclient.api.entity_factory.get_entity_id_bundle2", - new_callable=AsyncMock, - return_value=( - { - "entity": { - "concreteType": concrete_types.FOLDER_ENTITY, - "id": folder.id, - } - } - ), - ) as mocked_get: - result = folder.store(parent=Folder(id=PARENT_ID), synapse_client=self.syn) - - # THEN we should call the method with this data - mocked_client_call.assert_called_once() - call_args = mocked_client_call.call_args - assert call_args.kwargs["entity_id"] == SYN_123 # From findEntityId mock - assert call_args.kwargs["new_version"] is False - assert call_args.kwargs["synapse_client"] == self.syn - # The request should be a dict with the folder properties - request_dict = call_args.kwargs["request"] - assert ( - request_dict["concreteType"] == "org.sagebionetworks.repo.model.Folder" - ) - assert request_dict["name"] == folder.name - assert request_dict["parentId"] == PARENT_ID - assert request_dict["description"] == description - - # AND we should call the get method - mocked_get.assert_called_once() - - # AND findEntityId should be called - mocked_get.assert_called_once() - - # AND the folder should be stored - assert result.id == SYN_123 - assert result.name == FOLDER_NAME - assert result.parent_id == PARENT_ID - assert result.description == DESCRIPTION - assert result.etag == ETAG - assert result.created_on == CREATED_ON - assert result.modified_on == MODIFIED_ON - assert result.created_by == CREATED_BY - assert result.modified_by == MODIFIED_BY - - def test_store_no_id_name_or_parent(self) -> None: - # GIVEN a Folder object - folder = Folder() - - # WHEN I call `store` with the Folder object - with pytest.raises(ValueError) as e: - folder.store(synapse_client=self.syn) - - # THEN we should get an error - assert ( - str(e.value) == "The folder must have an id or a " - "(name and (`parent_id` or parent with an id)) set." - ) - - def test_store_no_id_or_name(self) -> None: - # GIVEN a Folder object - folder = Folder(parent_id=PARENT_ID) - - # WHEN I call `store` with the Folder object - with pytest.raises(ValueError) as e: - folder.store(synapse_client=self.syn) - - # THEN we should get an error - assert ( - str(e.value) == "The folder must have an id or a " - "(name and (`parent_id` or parent with an id)) set." - ) - - def test_store_no_id_or_parent(self) -> None: - # GIVEN a Folder object - folder = Folder(name=FOLDER_NAME) - - # WHEN I call `store` with the Folder object - with pytest.raises(ValueError) as e: - folder.store(synapse_client=self.syn) - - # THEN we should get an error - assert ( - str(e.value) == "The folder must have an id or a " - "(name and (`parent_id` or parent with an id)) set." - ) - - def test_get_by_id(self) -> None: - # GIVEN a Folder object - folder = Folder( - id=SYN_123, - ) - - # WHEN I call `get` with the Folder object - with patch( - "synapseclient.api.entity_factory.get_entity_id_bundle2", - new_callable=AsyncMock, - return_value=self.get_example_rest_api_folder_output(), - ) as mocked_client_call: - result = folder.get(synapse_client=self.syn) - - # THEN we should call the method with this data - mocked_client_call.assert_called_once_with( - entity_id=folder.id, synapse_client=self.syn - ) - - # AND the folder should be stored - assert result.id == SYN_123 - assert result.name == FOLDER_NAME - assert result.parent_id == PARENT_ID - assert result.description == DESCRIPTION - assert result.etag == ETAG - assert result.created_on == CREATED_ON - assert result.modified_on == MODIFIED_ON - assert result.created_by == CREATED_BY - assert result.modified_by == MODIFIED_BY - - def test_get_by_name_and_parent(self) -> None: - # GIVEN a Folder object - folder = Folder( - name=FOLDER_NAME, - parent_id=PARENT_ID, - ) - - # WHEN I call `get` with the Folder object - with patch.object( - self.syn, - "findEntityId", - return_value=(SYN_123), - ) as mocked_client_search, patch( - "synapseclient.api.entity_factory.get_entity_id_bundle2", - new_callable=AsyncMock, - return_value=self.get_example_rest_api_folder_output(), - ) as mocked_client_call: - result = folder.get(synapse_client=self.syn) - - # THEN we should call the method with this data - mocked_client_call.assert_called_once_with( - entity_id=folder.id, synapse_client=self.syn - ) - - # AND we should search for the entity - mocked_client_search.assert_called_once_with( - name=folder.name, - parent=folder.parent_id, - ) - - # AND the folder should be stored - assert result.id == SYN_123 - assert result.name == FOLDER_NAME - assert result.parent_id == PARENT_ID - assert result.description == DESCRIPTION - assert result.etag == ETAG - assert result.created_on == CREATED_ON - assert result.modified_on == MODIFIED_ON - assert result.created_by == CREATED_BY - assert result.modified_by == MODIFIED_BY - - def test_get_by_name_and_parent_not_found(self) -> None: - # GIVEN a Folder object - folder = Folder( - name=FOLDER_NAME, - parent_id=PARENT_ID, - ) - - # WHEN I call `get` with the Folder object - with patch.object( - self.syn, - "findEntityId", - return_value=(None), - ) as mocked_client_search: - with pytest.raises(SynapseNotFoundError) as e: - folder.get(synapse_client=self.syn) - assert ( - str(e.value) - == "Folder [Id: None, Name: example_folder, Parent: parent_id_value] not found in Synapse." - ) - - mocked_client_search.assert_called_once_with( - name=folder.name, - parent=folder.parent_id, - ) - - def test_delete_with_id(self) -> None: - # GIVEN a Folder object - folder = Folder( - id=SYN_123, - ) - - # WHEN I call `delete` with the Folder object - with patch.object( - self.syn, - "delete", - return_value=(None), - ) as mocked_client_call: - folder.delete(synapse_client=self.syn) - - # THEN we should call the method with this data - mocked_client_call.assert_called_once_with( - obj=folder.id, - ) - - def test_delete_missing_id(self) -> None: - # GIVEN a Folder object - folder = Folder() - - # WHEN I call `delete` with the Folder object - with pytest.raises(ValueError) as e: - folder.delete(synapse_client=self.syn) - - # THEN we should get an error - assert str(e.value) == "The folder must have an id set." - - def test_copy(self) -> None: - # GIVEN a Folder object - folder = Folder( - id=SYN_123, - ) - - # AND a returned Folder object - returned_folder = Folder(id=SYN_456) - - # AND a copy mapping exists - copy_mapping = { - SYN_123: SYN_456, - } - - # WHEN I call `copy` with the Folder object - with patch( - "synapseclient.models.folder.copy", - return_value=(copy_mapping), - ) as mocked_copy, patch( - "synapseclient.models.folder.Folder.get_async", - return_value=(returned_folder), - ) as mocked_get, patch( - "synapseclient.models.folder.Folder.sync_from_synapse_async", - return_value=(returned_folder), - ) as mocked_sync: - result = folder.copy(parent_id="destination_id", synapse_client=self.syn) - - # THEN we should call the method with this data - mocked_copy.assert_called_once_with( - syn=self.syn, - entity=folder.id, - destinationId="destination_id", - excludeTypes=[], - skipCopyAnnotations=False, - updateExisting=False, - setProvenance="traceback", - ) - - # AND we should call the get method - mocked_get.assert_called_once() - - # AND we should call the sync method - mocked_sync.assert_called_once_with( - download_file=False, - synapse_client=self.syn, - ) - - # AND the file should be stored - assert result.id == SYN_456 - - def test_copy_missing_id(self) -> None: - # GIVEN a Folder object - folder = Folder() - - # WHEN I call `copy` with the Folder object - with pytest.raises(ValueError) as e: - folder.copy(parent_id="destination_id", synapse_client=self.syn) - - # THEN we should get an error - assert str(e.value) == "The folder must have an ID and parent_id to copy." - - def test_copy_missing_destination(self) -> None: - # GIVEN a Folder object - folder = Folder(id=SYN_123) - - # WHEN I call `copy` with the Folder object - with pytest.raises(ValueError) as e: - folder.copy(parent_id=None, synapse_client=self.syn) - - # THEN we should get an error - assert str(e.value) == "The folder must have an ID and parent_id to copy." - - def test_sync_from_synapse(self) -> None: - # GIVEN a Folder object - folder = Folder( - id=SYN_123, - ) - - # AND Children that exist on the folder in Synapse - children = [ - { - "id": SYN_456, - "type": FILE_ENTITY, - "name": "example_file_1", - } - ] - - # WHEN I call `sync_from_synapse` with the Folder object - async def mock_get_children(*args, **kwargs): - for child in children: - yield child - - with patch( - "synapseclient.models.mixins.storable_container.get_children", - side_effect=mock_get_children, - ) as mocked_children_call, patch( - "synapseclient.api.entity_factory.get_entity_id_bundle2", - new_callable=AsyncMock, - return_value=self.get_example_rest_api_folder_output(), - ) as mocked_folder_get, patch( - "synapseclient.models.file.File.get_async", - return_value=(File(id=SYN_456, name="example_file_1")), - ): - result = folder.sync_from_synapse(synapse_client=self.syn) - - # THEN we should call the method with this data - mocked_children_call.assert_called_once() - - # AND we should call the get method - mocked_folder_get.assert_called_once() - - # AND the file/folder should be retrieved - assert result.id == SYN_123 - assert result.name == FOLDER_NAME - assert result.parent_id == PARENT_ID - assert result.description == DESCRIPTION - assert result.etag == ETAG - assert result.created_on == CREATED_ON - assert result.modified_on == MODIFIED_ON - assert result.created_by == CREATED_BY - assert result.modified_by == MODIFIED_BY - assert result.files[0].id == SYN_456 - assert result.files[0].name == "example_file_1" diff --git a/tests/unit/synapseclient/models/synchronous/unit_test_form.py b/tests/unit/synapseclient/models/synchronous/unit_test_form.py deleted file mode 100644 index fc469cb55..000000000 --- a/tests/unit/synapseclient/models/synchronous/unit_test_form.py +++ /dev/null @@ -1,316 +0,0 @@ -import os -from unittest.mock import patch - -import pytest - -from synapseclient.models import FormData, FormGroup -from synapseclient.models.mixins import StateEnum - - -class TestFormGroup: - """Unit tests for the FormGroup model.""" - - @pytest.fixture - def mock_response(self): - """Mock API response from create_form_group""" - return { - "groupId": "12345", - "name": "my_test_form_group", - "createdOn": "2023-12-01T10:00:00.000Z", - "createdBy": "3350396", - "modifiedOn": "2023-12-01T10:00:00.000Z", - } - - def test_create_success(self, syn, mock_response): - """Test successful form group creation""" - # GIVEN a FormGroup with a name - form_group = FormGroup(name="my_test_form_group") - - # WHEN creating the form group - with patch( - "synapseclient.api.form_services.create_form_group", - return_value=mock_response, - ) as mock_create: - result = form_group.create_or_get(synapse_client=syn) - - # THEN the API should be called with correct parameters - mock_create.assert_called_once_with( - synapse_client=syn, - name="my_test_form_group", - ) - - # AND the result should be a FormGroup with populated fields - assert isinstance(result, FormGroup) - assert result.name == "my_test_form_group" - assert result.group_id == "12345" - assert result.created_by == "3350396" - assert result.created_on == "2023-12-01T10:00:00.000Z" - - def test_create_without_name_raises_error(self, syn): - """Test that creating without a name raises ValueError""" - # GIVEN a FormGroup without a name - form_group = FormGroup() - - # WHEN creating the form group - # THEN it should raise ValueError - with pytest.raises(ValueError, match="FormGroup 'name' must be provided"): - form_group.create_or_get(synapse_client=syn) - - @pytest.mark.parametrize( - "as_reviewer,filter_by_state, expected_filter_by_state", - [ - # Test for non-reviewers - allow all possible state filters - ( - False, - [], - [ - StateEnum.WAITING_FOR_SUBMISSION, - StateEnum.SUBMITTED_WAITING_FOR_REVIEW, - StateEnum.ACCEPTED, - StateEnum.REJECTED, - ], - ), - # Test for reviewers - only allow review-related state filters - ( - True, - [], - [ - StateEnum.SUBMITTED_WAITING_FOR_REVIEW, - StateEnum.ACCEPTED, - StateEnum.REJECTED, - ], - ), - # Test for non-reviewers - only allow selected state filters - (False, ["accepted", "rejected"], [StateEnum.ACCEPTED, StateEnum.REJECTED]), - # Test for reviewers - only allow selected state filters - ( - True, - ["submitted_waiting_for_review", "rejected"], - [StateEnum.SUBMITTED_WAITING_FOR_REVIEW, StateEnum.REJECTED], - ), - ], - ) - def test_list(self, syn, as_reviewer, filter_by_state, expected_filter_by_state): - """Test listing form data""" - # Mock the create_or_get response - mock_create_response = { - "groupId": "12345", - "name": "my_form_group_name", - "createdOn": "2023-12-01T10:00:00.000Z", - "createdBy": "123456", - "modifiedOn": "2023-12-01T10:00:00.000Z", - } - - with patch( - "synapseclient.api.form_services.create_form_group", - return_value=mock_create_response, - ): - form_group = FormGroup(name="my_form_group_name") - form_group = form_group.create_or_get(synapse_client=syn) - - async def mock_form_data_list(): - yield { - "formDataId": "11111", - "groupId": "12345", - "name": "form_data_1", - "dataFileHandleId": "fh_1", - } - yield { - "formDataId": "22222", - "groupId": "12345", - "name": "form_data_2", - "dataFileHandleId": "fh_2", - } - yield { - "formDataId": "33333", - "groupId": "12345", - "name": "form_data_3", - "dataFileHandleId": "fh_3", - } - - async def mock_generator(): - async for item in mock_form_data_list(): - yield item - - # WHEN listing the form data - with patch( - "synapseclient.api.list_form_data", - return_value=mock_generator(), - ) as mock_list_form: - results = [] - - for item in form_group.list( - synapse_client=syn, - filter_by_state=filter_by_state, - as_reviewer=as_reviewer, - ): - results.append(item) - - # THEN the results should be a list of FormData objects - assert len(results) == 3 - - assert all(isinstance(item, FormData) for item in results) - assert results[0].form_data_id == "11111" - assert results[1].form_data_id == "22222" - assert results[2].form_data_id == "33333" - - # THEN the API should be called with correct parameters - mock_list_form.assert_called_once_with( - synapse_client=syn, - group_id="12345", - filter_by_state=expected_filter_by_state, - as_reviewer=as_reviewer, - ) - - @pytest.mark.parametrize( - "as_reviewer,filter_by_state, expected", - [ - # Test for non-reviewers - WAITING_FOR_SUBMISSION is allowed - (False, ["waiting_for_submission", "accepted"], None), - # Test for reviewers - invalid state filter - (True, ["waiting_for_submission"], ValueError), - ], - ) - def test_validate_filter_by_state_raises_error_for_invalid_states( - self, as_reviewer, filter_by_state, expected, syn - ): - """Test that invalid state filters raise ValueError""" - # Mock the create_or_get response - mock_create_response = { - "groupId": "12345", - "name": "my_form_group_name", - "createdOn": "2023-12-01T10:00:00.000Z", - "createdBy": "123456", - "modifiedOn": "2023-12-01T10:00:00.000Z", - } - - with patch( - "synapseclient.api.form_services.create_form_group", - return_value=mock_create_response, - ): - form_group = FormGroup(name="my_form_group_name") - form_group = form_group.create_or_get(synapse_client=syn) - - # WHEN validating filter_by_state with invalid states for non-reviewer - # THEN it should raise ValueError - if expected is ValueError: - with pytest.raises(ValueError): - # Call the private method directly for testing - form_group._validate_filter_by_state( - filter_by_state=filter_by_state, - as_reviewer=as_reviewer, - ) - - -class TestFormData: - """Unit tests for the FormData model.""" - - @pytest.fixture - def mock_response(self): - """Mock API response from create_form_data""" - return { - "formDataId": "67890", - "groupId": "12345", - "name": "my_test_form_data", - "dataFileHandleId": "54321", - "createdOn": "2023-12-01T11:00:00.000Z", - "createdBy": "3350396", - "modifiedOn": "2023-12-01T11:00:00.000Z", - "submissionStatus": { - "state": "SUBMITTED_WAITING_FOR_REVIEW", - "submittedOn": "2023-12-01T11:05:00.000Z", - "reviewedBy": None, - "reviewedOn": None, - "rejectionReason": None, - }, - } - - def test_create_success(self, syn, mock_response): - """Test successful form data creation""" - # GIVEN a FormData with required fields - form_data = FormData( - group_id="12345", - name="my_test_form_data", - data_file_handle_id="54321", - ) - - # WHEN creating the form data - with patch( - "synapseclient.api.create_form_data", - return_value=mock_response, - ) as mock_create_form: - result = form_data.create_or_get(synapse_client=syn) - - # THEN the API should be called with correct parameters - mock_create_form.assert_called_once_with( - synapse_client=syn, - group_id="12345", - form_change_request={ - "name": "my_test_form_data", - "fileHandleId": "54321", - }, - ) - - # AND the result should be a FormData with populated fields - assert isinstance(result, FormData) - assert result.name == "my_test_form_data" - assert result.form_data_id == "67890" - assert result.group_id == "12345" - assert result.data_file_handle_id == "54321" - assert result.created_by == "3350396" - assert ( - result.submission_status.state.value == "SUBMITTED_WAITING_FOR_REVIEW" - ) - - def test_create_without_required_fields_raises_error(self, syn): - """Test that creating without required fields raises ValueError""" - # GIVEN a FormData missing required fields - form_data = FormData(name="incomplete_form_data") - - # WHEN creating the form data - # THEN it should raise ValueError - with pytest.raises( - ValueError, - match="'group_id', 'name', and 'data_file_handle_id' are required", - ): - form_data.create_or_get(synapse_client=syn) - - def test_download(self, syn): - """Test downloading form data""" - # GIVEN a FormData with a form_data_id - form_data = FormData(form_data_id="67890", data_file_handle_id="54321") - - # WHEN downloading the form data - with patch( - "synapseclient.core.download.download_functions.download_by_file_handle", - ) as mock_download_file_handle, patch.object(syn, "cache") as mock_cache, patch( - "synapseclient.core.download.download_functions.ensure_download_location_is_directory", - ) as mock_ensure_dir: - mock_cache.get.side_effect = "/tmp/foo" - mock_ensure_dir.return_value = ( - mock_cache.get_cache_dir.return_value - ) = "/tmp/download" - mock_file_name = f"SYNAPSE_FORM_{form_data.data_file_handle_id}.csv" - - form_data.download(synapse_client=syn, synapse_id="mock synapse_id") - - # THEN the API should be called with correct parameters - mock_download_file_handle.assert_called_once_with( - file_handle_id=form_data.data_file_handle_id, - synapse_id="mock synapse_id", - entity_type="FileEntity", - destination=os.path.join(mock_ensure_dir.return_value, mock_file_name), - synapse_client=syn, - ) - - def test_download_without_data_file_handle_id_raises_error(self, syn): - """Test that downloading without data_file_handle_id raises ValueError""" - # GIVEN a FormData without a data_file_handle_id - form_data = FormData(form_data_id="67890") - - # WHEN downloading the form data - # THEN it should raise ValueError - with pytest.raises( - ValueError, match="data_file_handle_id must be set to download the file." - ): - form_data.download(synapse_client=syn, synapse_id="mock synapse_id") diff --git a/tests/unit/synapseclient/models/synchronous/unit_test_project.py b/tests/unit/synapseclient/models/synchronous/unit_test_project.py deleted file mode 100644 index 8e09f2e48..000000000 --- a/tests/unit/synapseclient/models/synchronous/unit_test_project.py +++ /dev/null @@ -1,686 +0,0 @@ -"""Tests for the synapseclient.models.Project class.""" -import uuid -from typing import Dict -from unittest.mock import AsyncMock, patch - -import pytest - -from synapseclient import Project as Synapse_Project -from synapseclient import Synapse -from synapseclient.core.constants import concrete_types -from synapseclient.core.constants.concrete_types import FILE_ENTITY -from synapseclient.core.exceptions import SynapseNotFoundError -from synapseclient.models import FailureStrategy, File, Project - -PROJECT_ID = "syn123" -DERSCRIPTION_PROJECT = "This is an example project." -PARENT_ID = "parent_id_value" -PROJECT_NAME = "example_project" -ETAG = "etag_value" -CREATED_ON = "createdOn_value" -MODIFIED_ON = "modifiedOn_value" -CREATED_BY = "createdBy_value" -MODIFIED_BY = "modifiedBy_value" - - -class TestProject: - """Tests for the synapseclient.models.Project class.""" - - @pytest.fixture(autouse=True, scope="function") - def init_syn(self, syn: Synapse) -> None: - self.syn = syn - - def get_example_synapse_project_output(self) -> Synapse_Project: - return Synapse_Project( - id=PROJECT_ID, - name=PROJECT_NAME, - parentId=PARENT_ID, - description=DERSCRIPTION_PROJECT, - etag=ETAG, - createdOn=CREATED_ON, - modifiedOn=MODIFIED_ON, - createdBy=CREATED_BY, - modifiedBy=MODIFIED_BY, - ) - - def get_example_rest_api_project_output(self) -> Dict[str, str]: - return { - "entity": { - "concreteType": concrete_types.PROJECT_ENTITY, - "id": PROJECT_ID, - "name": PROJECT_NAME, - "parentId": PARENT_ID, - "description": DERSCRIPTION_PROJECT, - "etag": ETAG, - "createdOn": CREATED_ON, - "modifiedOn": MODIFIED_ON, - "createdBy": CREATED_BY, - "modifiedBy": MODIFIED_BY, - } - } - - def test_fill_from_dict(self) -> None: - # GIVEN an example Synapse Project `get_example_synapse_project_output` - # WHEN I call `fill_from_dict` with the example Synapse Project - project_output = Project().fill_from_dict( - self.get_example_synapse_project_output() - ) - - # THEN the Project object should be filled with the example Synapse Project - assert project_output.id == PROJECT_ID - assert project_output.name == PROJECT_NAME - assert project_output.parent_id == PARENT_ID - assert project_output.description == DERSCRIPTION_PROJECT - assert project_output.etag == ETAG - assert project_output.created_on == CREATED_ON - assert project_output.modified_on == MODIFIED_ON - assert project_output.created_by == CREATED_BY - assert project_output.modified_by == MODIFIED_BY - - def test_store_with_id(self) -> None: - # GIVEN a Project object - project = Project( - id=PROJECT_ID, - ) - - # AND a random description - description = str(uuid.uuid4()) - project.description = description - - # WHEN I call `store` with the Project object - with patch( - "synapseclient.models.services.storable_entity.put_entity", - new_callable=AsyncMock, - return_value=(self.get_example_synapse_project_output()), - ) as mocked_client_call, patch( - "synapseclient.api.entity_factory.get_entity_id_bundle2", - new_callable=AsyncMock, - return_value=( - { - "entity": { - "concreteType": concrete_types.PROJECT_ENTITY, - "id": project.id, - } - } - ), - ) as mocked_get: - result = project.store(synapse_client=self.syn) - - # THEN we should call the method with this data - mocked_client_call.assert_called_once() - call_args = mocked_client_call.call_args - assert call_args.kwargs["entity_id"] == project.id - assert call_args.kwargs["new_version"] is False - assert call_args.kwargs["synapse_client"] == self.syn - # The request should be a dict with the project properties - request_dict = call_args.kwargs["request"] - assert ( - request_dict["concreteType"] == "org.sagebionetworks.repo.model.Project" - ) - assert request_dict["id"] == project.id - assert request_dict["description"] == description - - # AND we should call the get method - mocked_get.assert_called_once() - - # AND the project should be stored with the mock return data - assert result.id == PROJECT_ID - assert result.name == PROJECT_NAME - assert result.parent_id == PARENT_ID - assert result.description == DERSCRIPTION_PROJECT - assert result.etag == ETAG - assert result.created_on == CREATED_ON - assert result.modified_on == MODIFIED_ON - assert result.created_by == CREATED_BY - assert result.modified_by == MODIFIED_BY - - def test_store_with_no_changes(self) -> None: - # GIVEN a Project object - project = Project( - id=PROJECT_ID, - ) - - # WHEN I call `store` with the Project object - with patch.object( - self.syn, - "store", - ) as mocked_store, patch( - "synapseclient.api.entity_factory.get_entity_id_bundle2", - new_callable=AsyncMock, - return_value=( - { - "entity": { - "concreteType": concrete_types.PROJECT_ENTITY, - "id": project.id, - } - } - ), - ) as mocked_get: - result = project.store(synapse_client=self.syn) - - # THEN we should not call store because there are no changes - mocked_store.assert_not_called() - - # AND we should call the get method - mocked_get.assert_called_once() - - # AND the project should only contain the ID - assert result.id == PROJECT_ID - - def test_store_after_get(self) -> None: - # GIVEN a Project object - project = Project( - id=PROJECT_ID, - ) - - # AND I call `get` on the Project object - with patch( - "synapseclient.api.entity_factory.get_entity_id_bundle2", - new_callable=AsyncMock, - return_value=( - { - "entity": { - "concreteType": concrete_types.PROJECT_ENTITY, - "id": project.id, - } - } - ), - ) as mocked_get: - project.get(synapse_client=self.syn) - - mocked_get.assert_called_once_with( - entity_id=project.id, synapse_client=self.syn - ) - assert project.id == PROJECT_ID - - # WHEN I call `store` with the Project object - with patch.object( - self.syn, - "store", - ) as mocked_store, patch( - "synapseclient.api.entity_factory.get_entity_id_bundle2", - new_callable=AsyncMock, - return_value=( - { - "entity": { - "concreteType": concrete_types.PROJECT_ENTITY, - "id": project.id, - } - } - ), - ) as mocked_get: - result = project.store(synapse_client=self.syn) - - # THEN we should not call store because there are no changes - mocked_store.assert_not_called() - - # AND we should not call get as we already have - mocked_get.assert_not_called() - - # AND the project should only contain the ID - assert result.id == PROJECT_ID - - def test_store_after_get_with_changes(self) -> None: - # GIVEN a Project object - project = Project( - id=PROJECT_ID, - ) - - # AND I call `get` on the Project object - with patch( - "synapseclient.api.entity_factory.get_entity_id_bundle2", - new_callable=AsyncMock, - return_value=( - { - "entity": { - "concreteType": concrete_types.PROJECT_ENTITY, - "id": project.id, - } - } - ), - ) as mocked_get: - project.get(synapse_client=self.syn) - - mocked_get.assert_called_once_with( - entity_id=project.id, synapse_client=self.syn - ) - assert project.id == PROJECT_ID - - # AND I update a field on the project - description = str(uuid.uuid4()) - project.description = description - - # WHEN I call `store` with the Project object - with patch( - "synapseclient.models.services.storable_entity.put_entity", - new_callable=AsyncMock, - return_value=(self.get_example_synapse_project_output()), - ) as mocked_store, patch( - "synapseclient.api.entity_factory.get_entity_id_bundle2", - ) as mocked_get: - result = project.store(synapse_client=self.syn) - - # THEN we should call store because there are changes - mocked_store.assert_called_once() - call_args = mocked_store.call_args - assert call_args.kwargs["entity_id"] == project.id - assert call_args.kwargs["new_version"] is False - assert call_args.kwargs["synapse_client"] == self.syn - # The request should be a dict with the project properties - request_dict = call_args.kwargs["request"] - assert ( - request_dict["concreteType"] == "org.sagebionetworks.repo.model.Project" - ) - assert request_dict["id"] == project.id - assert request_dict["description"] == description - - # AND we should not call get as we already have - mocked_get.assert_not_called() - - # AND the project should contained the mocked store return data - assert result.id == PROJECT_ID - assert result.name == PROJECT_NAME - assert result.parent_id == PARENT_ID - assert result.description == DERSCRIPTION_PROJECT - assert result.etag == ETAG - assert result.created_on == CREATED_ON - assert result.modified_on == MODIFIED_ON - assert result.created_by == CREATED_BY - assert result.modified_by == MODIFIED_BY - - def test_store_with_annotations(self) -> None: - # GIVEN a Project object - project = Project( - id=PROJECT_ID, - annotations={ - "my_single_key_string": ["a"], - "my_key_string": ["b", "a", "c"], - "my_key_bool": [False, False, False], - "my_key_double": [1.2, 3.4, 5.6], - "my_key_long": [1, 2, 3], - }, - ) - - # AND a random description - description = str(uuid.uuid4()) - project.description = description - - # WHEN I call `store` with the Project object - with patch( - "synapseclient.models.project.store_entity_components", - return_value=(None), - ) as mocked_store_entity_components, patch( - "synapseclient.models.services.storable_entity.put_entity", - new_callable=AsyncMock, - return_value=(self.get_example_synapse_project_output()), - ) as mocked_client_call, patch( - "synapseclient.api.entity_factory.get_entity_id_bundle2", - new_callable=AsyncMock, - return_value=( - { - "entity": { - "concreteType": concrete_types.PROJECT_ENTITY, - "id": project.id, - } - } - ), - ) as mocked_get: - result = project.store(synapse_client=self.syn) - - # THEN we should call the method with this data - mocked_client_call.assert_called_once() - assert mocked_client_call.call_args.kwargs["entity_id"] == PROJECT_ID - assert mocked_client_call.call_args.kwargs["new_version"] is False - assert mocked_client_call.call_args.kwargs["synapse_client"] == self.syn - request_dict = mocked_client_call.call_args.kwargs["request"] - assert request_dict["id"] == PROJECT_ID - assert request_dict["description"] == description - assert request_dict["concreteType"] == concrete_types.PROJECT_ENTITY - - # AND we should call the get method - mocked_get.assert_called_once() - - # AND we should store the annotations component - mocked_store_entity_components.assert_called_once_with( - root_resource=project, - failure_strategy=FailureStrategy.LOG_EXCEPTION, - synapse_client=self.syn, - ) - - # AND the project should be stored with the mock return data - assert result.id == PROJECT_ID - assert result.name == PROJECT_NAME - assert result.parent_id == PARENT_ID - assert result.description == DERSCRIPTION_PROJECT - assert result.etag == ETAG - assert result.created_on == CREATED_ON - assert result.modified_on == MODIFIED_ON - assert result.created_by == CREATED_BY - assert result.modified_by == MODIFIED_BY - - def test_store_with_name_and_parent_id(self) -> None: - # GIVEN a Project object - project = Project( - name=PROJECT_NAME, - parent_id=PARENT_ID, - ) - - # AND a random description - description = str(uuid.uuid4()) - project.description = description - - # WHEN I call `store` with the Project object - with patch( - "synapseclient.models.services.storable_entity.put_entity", - new_callable=AsyncMock, - return_value=(self.get_example_synapse_project_output()), - ) as mocked_client_call, patch.object( - self.syn, - "findEntityId", - return_value=PROJECT_ID, - ) as mocked_get, patch( - "synapseclient.api.entity_factory.get_entity_id_bundle2", - new_callable=AsyncMock, - return_value=( - { - "entity": { - "concreteType": concrete_types.PROJECT_ENTITY, - "id": project.id, - } - } - ), - ) as mocked_get: - result = project.store(synapse_client=self.syn) - - # THEN we should call the method with this data - mocked_client_call.assert_called_once() - assert mocked_client_call.call_args.kwargs["entity_id"] == PROJECT_ID - assert mocked_client_call.call_args.kwargs["new_version"] is False - assert mocked_client_call.call_args.kwargs["synapse_client"] == self.syn - request_dict = mocked_client_call.call_args.kwargs["request"] - assert request_dict["id"] == PROJECT_ID - assert request_dict["name"] == PROJECT_NAME - assert request_dict["parentId"] == PARENT_ID - assert request_dict["description"] == description - assert request_dict["concreteType"] == concrete_types.PROJECT_ENTITY - - # AND we should call the get method - mocked_get.assert_called_once() - - # AND findEntityId should be called - mocked_get.assert_called_once() - - # AND the project should be stored - assert result.id == PROJECT_ID - assert result.name == PROJECT_NAME - assert result.parent_id == PARENT_ID - assert result.description == DERSCRIPTION_PROJECT - assert result.etag == ETAG - assert result.created_on == CREATED_ON - assert result.modified_on == MODIFIED_ON - assert result.created_by == CREATED_BY - assert result.modified_by == MODIFIED_BY - - def test_store_no_id_or_name(self) -> None: - # GIVEN a Project object - project = Project(parent_id=PARENT_ID) - - # WHEN I call `store` with the Project object - with pytest.raises(ValueError) as e: - project.store(synapse_client=self.syn) - - # THEN we should get an error - assert str(e.value) == "Project ID or Name is required" - - def test_get_by_id(self) -> None: - # GIVEN a Project object - project = Project( - id=PROJECT_ID, - ) - - # WHEN I call `get` with the Project object - with patch( - "synapseclient.api.entity_factory.get_entity_id_bundle2", - new_callable=AsyncMock, - return_value=(self.get_example_rest_api_project_output()), - ) as mocked_client_call: - result = project.get(synapse_client=self.syn) - - # THEN we should call the method with this data - mocked_client_call.assert_called_once_with( - entity_id=project.id, synapse_client=self.syn - ) - - # AND the project should be stored - assert result.id == PROJECT_ID - assert result.name == PROJECT_NAME - assert result.parent_id == PARENT_ID - assert result.description == DERSCRIPTION_PROJECT - assert result.etag == ETAG - assert result.created_on == CREATED_ON - assert result.modified_on == MODIFIED_ON - assert result.created_by == CREATED_BY - assert result.modified_by == MODIFIED_BY - - def test_get_by_name_and_parent(self) -> None: - # GIVEN a Project object - project = Project( - name=PROJECT_NAME, - parent_id=PARENT_ID, - ) - - # WHEN I call `get` with the Project object - with patch.object( - self.syn, - "findEntityId", - return_value=(PROJECT_ID), - ) as mocked_client_search, patch( - "synapseclient.api.entity_factory.get_entity_id_bundle2", - new_callable=AsyncMock, - return_value=(self.get_example_rest_api_project_output()), - ) as mocked_client_call: - result = project.get(synapse_client=self.syn) - - # THEN we should call the method with this data - mocked_client_call.assert_called_once_with( - entity_id=project.id, synapse_client=self.syn - ) - - # AND we should search for the entity - mocked_client_search.assert_called_once_with( - name=project.name, - parent=project.parent_id, - ) - - # AND the project should be stored - assert result.id == PROJECT_ID - assert result.name == PROJECT_NAME - assert result.parent_id == PARENT_ID - assert result.description == DERSCRIPTION_PROJECT - assert result.etag == ETAG - assert result.created_on == CREATED_ON - assert result.modified_on == MODIFIED_ON - assert result.created_by == CREATED_BY - assert result.modified_by == MODIFIED_BY - - def test_get_by_name_and_parent_not_found(self) -> None: - # GIVEN a Project object - project = Project( - name=PROJECT_NAME, - parent_id=PARENT_ID, - ) - - # WHEN I call `get` with the Project object - with patch.object( - self.syn, - "findEntityId", - return_value=(None), - ) as mocked_client_search: - with pytest.raises(SynapseNotFoundError) as e: - project.get(synapse_client=self.syn) - assert ( - str(e.value) - == "Project [Id: None, Name: example_project, Parent: parent_id_value] not found in Synapse." - ) - - mocked_client_search.assert_called_once_with( - name=project.name, - parent=project.parent_id, - ) - - def test_delete_with_id(self) -> None: - # GIVEN a Project object - project = Project( - id=PROJECT_ID, - ) - - # WHEN I call `delete` with the Project object - with patch.object( - self.syn, - "delete", - return_value=(None), - ) as mocked_client_call: - project.delete(synapse_client=self.syn) - - # THEN we should call the method with this data - mocked_client_call.assert_called_once_with( - obj=project.id, - ) - - def test_delete_missing_id(self) -> None: - # GIVEN a Project object - project = Project() - - # WHEN I call `delete` with the Project object - with pytest.raises(ValueError) as e: - project.delete(synapse_client=self.syn) - - # THEN we should get an error - assert str(e.value) == "Entity ID or Name/Parent is required" - - def test_copy(self) -> None: - # GIVEN a Project object - project = Project( - id=PROJECT_ID, - ) - - # AND a returned Project object - returned_project = Project(id="syn456") - - # AND a copy mapping exists - copy_mapping = { - PROJECT_ID: "syn456", - } - - # WHEN I call `copy` with the Project object - with patch( - "synapseclient.models.project.copy", - return_value=(copy_mapping), - ) as mocked_copy, patch( - "synapseclient.models.project.Project.get_async", - return_value=(returned_project), - ) as mocked_get, patch( - "synapseclient.models.project.Project.sync_from_synapse_async", - return_value=(returned_project), - ) as mocked_sync: - result = project.copy( - destination_id="destination_id", synapse_client=self.syn - ) - - # THEN we should call the method with this data - mocked_copy.assert_called_once_with( - syn=self.syn, - entity=project.id, - destinationId="destination_id", - excludeTypes=[], - skipCopyAnnotations=False, - skipCopyWikiPage=False, - updateExisting=False, - setProvenance="traceback", - ) - - # AND we should call the get method - mocked_get.assert_called_once() - - # AND we should call the sync method - mocked_sync.assert_called_once_with( - download_file=False, - synapse_client=self.syn, - ) - - # AND the file should be stored - assert result.id == "syn456" - - def test_copy_missing_id(self) -> None: - # GIVEN a Project object - project = Project() - - # WHEN I call `copy` with the Project object - with pytest.raises(ValueError) as e: - project.copy(destination_id="destination_id", synapse_client=self.syn) - - # THEN we should get an error - assert str(e.value) == "The project must have an ID and destination_id to copy." - - def test_copy_missing_destination(self) -> None: - # GIVEN a Project object - project = Project(id=PROJECT_ID) - - # WHEN I call `copy` with the Project object - with pytest.raises(ValueError) as e: - project.copy(destination_id=None, synapse_client=self.syn) - - # THEN we should get an error - assert str(e.value) == "The project must have an ID and destination_id to copy." - - def test_sync_from_synapse(self) -> None: - # GIVEN a Project object - project = Project( - id=PROJECT_ID, - ) - - # AND Children that exist on the project in Synapse - children = [ - { - "id": "syn456", - "type": FILE_ENTITY, - "name": "example_file_1", - } - ] - - # WHEN I call `sync_from_synapse` with the Project object - async def mock_get_children(*args, **kwargs): - for child in children: - yield child - - with patch( - "synapseclient.models.mixins.storable_container.get_children", - side_effect=mock_get_children, - ) as mocked_children_call, patch( - "synapseclient.api.entity_factory.get_entity_id_bundle2", - new_callable=AsyncMock, - return_value=(self.get_example_rest_api_project_output()), - ) as mocked_project_get, patch( - "synapseclient.models.file.File.get_async", - return_value=(File(id="syn456", name="example_file_1")), - ): - result = project.sync_from_synapse(synapse_client=self.syn) - - # THEN we should call the method with this data - mocked_children_call.assert_called_once() - - # AND we should call the get method - mocked_project_get.assert_called_once() - - # AND the file/project should be retrieved - assert result.id == PROJECT_ID - assert result.name == PROJECT_NAME - assert result.parent_id == PARENT_ID - assert result.description == DERSCRIPTION_PROJECT - assert result.etag == ETAG - assert result.created_on == CREATED_ON - assert result.modified_on == MODIFIED_ON - assert result.created_by == CREATED_BY - assert result.modified_by == MODIFIED_BY - assert result.files[0].id == "syn456" - assert result.files[0].name == "example_file_1" diff --git a/tests/unit/synapseclient/models/synchronous/unit_test_schema_organization.py b/tests/unit/synapseclient/models/synchronous/unit_test_schema_organization.py deleted file mode 100644 index 745ac5aac..000000000 --- a/tests/unit/synapseclient/models/synchronous/unit_test_schema_organization.py +++ /dev/null @@ -1,191 +0,0 @@ -"""Unit tests for SchemaOrganization and JSONSchema classes""" -import pytest - -from synapseclient.models import JSONSchema, SchemaOrganization -from synapseclient.models.schema_organization import CreateSchemaRequest, _check_name - - -class TestSchemaOrganization: - """Synchronous unit tests for SchemaOrganization.""" - - def test_fill_from_dict(self) -> None: - "Tests that fill_from_dict fills in all fields" - organization = SchemaOrganization() - assert organization.name is None - assert organization.id is None - assert organization.created_by is None - assert organization.created_on is None - organization.fill_from_dict( - { - "name": "org.name", - "id": "org.id", - "createdOn": "1", - "createdBy": "2", - } - ) - assert organization.name == "org.name" - assert organization.id == "org.id" - assert organization.created_on == "1" - assert organization.created_by == "2" - - -class TestJSONSchema: - """Synchronous unit tests for JSONSchema.""" - - @pytest.mark.parametrize( - "uri", - ["ORG.NAME-SCHEMA.NAME", "ORG.NAME-SCHEMA.NAME-0.0.1"], - ids=["Non-semantic URI", "Semantic URI"], - ) - def test_from_uri(self, uri: str) -> None: - "Tests that legal schema URIs result in created objects." - assert JSONSchema.from_uri(uri) - - @pytest.mark.parametrize( - "uri", - ["ORG.NAME", "ORG.NAME-SCHEMA.NAME-0.0.1-extra.part"], - ids=["No dashes", "Too many dashes"], - ) - def test_from_uri_with_exceptions(self, uri: str) -> None: - "Tests that illegal schema URIs result in an exception." - with pytest.raises(ValueError, match="The URI must be in the form of"): - JSONSchema.from_uri(uri) - - def test_fill_from_dict(self) -> None: - "Tests that fill_from_dict fills in all fields" - js = JSONSchema() - assert js.name is None - assert js.organization_name is None - assert js.id is None - assert js.organization_id is None - assert js.created_on is None - assert js.created_by is None - assert js.uri is None - js.fill_from_dict( - { - "organizationId": "org.id", - "organizationName": "org.name", - "schemaId": "id", - "schemaName": "name", - "createdOn": "1", - "createdBy": "2", - } - ) - assert js.name == "name" - assert js.organization_name == "org.name" - assert js.id == "id" - assert js.organization_id == "org.id" - assert js.created_on == "1" - assert js.created_by == "2" - assert js.uri == "org.name-name" - - @pytest.mark.parametrize("version", ["1.0.0", "0.0.1", "0.1.0"]) - def test_check_semantic_version(self, version: str) -> None: - "Tests that only correct versions are allowed" - js = JSONSchema() - js._check_semantic_version(version) - - def test_check_semantic_version_with_exceptions(self) -> None: - "Tests that only correct versions are allowed" - js = JSONSchema() - with pytest.raises( - ValueError, match="Schema version must start at '0.0.1' or higher" - ): - js._check_semantic_version("0.0.0") - with pytest.raises( - ValueError, - match="Schema version must be a semantic version with no letters and a major, minor and patch version", - ): - js._check_semantic_version("0.0.1.rc") - - -class TestCreateSchemaRequest: - @pytest.mark.parametrize( - "version", - ["0.0.1", "1.0.0"], - ) - def test_init_version(self, version: str) -> None: - "Tests that legal versions don't raise a ValueError on init" - assert CreateSchemaRequest( - schema={}, name="schema.name", organization_name="org.name", version=version - ) - - @pytest.mark.parametrize( - "version", - ["1", "1.0", "0.0.0.1", "0.0.0"], - ) - def test_init_version_exceptions(self, version: str) -> None: - "Tests that illegal versions raise a ValueError on init" - with pytest.raises( - ValueError, - match="Schema version must be a semantic version starting at 0.0.1", - ): - CreateSchemaRequest( - schema={}, - name="schema.name", - organization_name="org.name", - version=version, - ) - - -class TestCheckName: - """Tests for check name helper function""" - - @pytest.mark.parametrize( - "name", - ["aaaaaaa", "aaaaaa1", "aa.aa.aa", "a1.a1.a1"], - ) - def test_check_name(self, name: str): - """Checks that legal names don't raise an exception""" - _check_name(name) - - @pytest.mark.parametrize( - "name", - [ - "a", - "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", - ], - ) - def test_check_length_exception(self, name: str): - """Checks that names that are too short or long raise an exception""" - with pytest.raises( - ValueError, match="The name must be of length 6 to 250 characters" - ): - _check_name(name) - - @pytest.mark.parametrize( - "name", - [ - "sagebionetworks", - "asagebionetworks", - "sagebionetworksa", - "aaa.sagebionetworks.aaa", - "SAGEBIONETWORKS", - "SageBionetworks", - ], - ) - def test_check_sage_exception(self, name: str): - """Checks that names that contain 'sagebionetworks' raise an exception""" - with pytest.raises( - ValueError, match="The name must not contain 'sagebionetworks'" - ): - _check_name(name) - - @pytest.mark.parametrize( - "name", - ["1AAAAA", "AAA.1AAA", "AAA.AAA.1AAA", ".AAAAAAA", "AAAAAAAA!"], - ids=[ - "Starts with number", - "Part2 starts with number", - "Part3 starts with number", - "Starts with period", - "Contains special characters", - ], - ) - def test_check_content_exception(self, name: str): - """Checks that names that contain special characters(besides periods) or have parts that start with numbers raise an exception""" - with pytest.raises( - ValueError, - match="Name may be separated by periods, but each part must start with a letter and contain only letters and numbers", - ): - _check_name(name) diff --git a/tests/unit/synapseclient/models/synchronous/unit_test_submission.py b/tests/unit/synapseclient/models/synchronous/unit_test_submission.py deleted file mode 100644 index 9070ce0ab..000000000 --- a/tests/unit/synapseclient/models/synchronous/unit_test_submission.py +++ /dev/null @@ -1,801 +0,0 @@ -"""Unit tests for the synapseclient.models.Submission class.""" -import uuid -from typing import Dict, List, Union -from unittest.mock import AsyncMock, MagicMock, call, patch - -import pytest - -from synapseclient import Synapse -from synapseclient.core.exceptions import SynapseHTTPError -from synapseclient.models import Submission - -SUBMISSION_ID = "9614543" -USER_ID = "123456" -SUBMITTER_ALIAS = "test_user" -ENTITY_ID = "syn789012" -VERSION_NUMBER = 1 -EVALUATION_ID = "9999999" -SUBMISSION_NAME = "Test Submission" -CREATED_ON = "2023-01-01T10:00:00.000Z" -TEAM_ID = "team123" -CONTRIBUTORS = ["user1", "user2", "user3"] -SUBMISSION_STATUS = {"status": "RECEIVED", "score": 85.5} -ENTITY_BUNDLE_JSON = '{"entity": {"id": "syn789012", "name": "test_entity"}}' -DOCKER_REPOSITORY_NAME = "test/repository" -DOCKER_DIGEST = "sha256:abc123def456" -ETAG = "etag_value" - - -class TestSubmission: - """Tests for the synapseclient.models.Submission class.""" - - @pytest.fixture(autouse=True, scope="function") - def init_syn(self, syn: Synapse) -> None: - self.syn = syn - - def get_example_submission_response(self) -> Dict[str, Union[str, int, List, Dict]]: - """Get a complete example submission response from the REST API.""" - return { - "id": SUBMISSION_ID, - "userId": USER_ID, - "submitterAlias": SUBMITTER_ALIAS, - "entityId": ENTITY_ID, - "versionNumber": VERSION_NUMBER, - "evaluationId": EVALUATION_ID, - "name": SUBMISSION_NAME, - "createdOn": CREATED_ON, - "teamId": TEAM_ID, - "contributors": CONTRIBUTORS, - "submissionStatus": SUBMISSION_STATUS, - "entityBundleJSON": ENTITY_BUNDLE_JSON, - "dockerRepositoryName": DOCKER_REPOSITORY_NAME, - "dockerDigest": DOCKER_DIGEST, - } - - def get_minimal_submission_response(self) -> Dict[str, str]: - """Get a minimal example submission response from the REST API.""" - return { - "id": SUBMISSION_ID, - "entityId": ENTITY_ID, - "evaluationId": EVALUATION_ID, - } - - def get_example_entity_response(self) -> Dict[str, Union[str, int]]: - """Get an example entity response for testing entity fetching.""" - return { - "id": ENTITY_ID, - "etag": ETAG, - "versionNumber": VERSION_NUMBER, - "name": "test_entity", - "concreteType": "org.sagebionetworks.repo.model.FileEntity", - } - - def get_example_docker_entity_response(self) -> Dict[str, Union[str, int]]: - """Get an example Docker repository entity response for testing.""" - return { - "id": ENTITY_ID, - "etag": ETAG, - "name": "test_docker_repo", - "concreteType": "org.sagebionetworks.repo.model.docker.DockerRepository", - "repositoryName": "test/repository", - } - - def get_example_docker_tag_response(self) -> Dict[str, Union[str, int, List]]: - """Get an example Docker tag response for testing.""" - return { - "totalNumberOfResults": 2, - "results": [ - { - "tag": "v1.0", - "digest": "sha256:older123def456", - "createdOn": "2024-01-01T10:00:00.000Z", - }, - { - "tag": "v2.0", - "digest": "sha256:latest456abc789", - "createdOn": "2024-06-01T15:30:00.000Z", - }, - ], - } - - def get_complex_docker_tag_response(self) -> Dict[str, Union[str, int, List]]: - """Get a more complex Docker tag response with multiple versions to test sorting.""" - return { - "totalNumberOfResults": 4, - "results": [ - { - "tag": "v1.0", - "digest": "sha256:version1", - "createdOn": "2024-01-01T10:00:00.000Z", - }, - { - "tag": "v3.0", - "digest": "sha256:version3", - "createdOn": "2024-08-15T12:00:00.000Z", # This should be selected (latest) - }, - { - "tag": "v2.0", - "digest": "sha256:version2", - "createdOn": "2024-06-01T15:30:00.000Z", - }, - { - "tag": "v1.5", - "digest": "sha256:version1_5", - "createdOn": "2024-03-15T08:45:00.000Z", - }, - ], - } - - def test_fill_from_dict_complete_data(self) -> None: - # GIVEN a complete submission response from the REST API - # WHEN I call fill_from_dict with the example submission response - submission = Submission().fill_from_dict(self.get_example_submission_response()) - - # THEN the Submission object should be filled with all the data - assert submission.id == SUBMISSION_ID - assert submission.user_id == USER_ID - assert submission.submitter_alias == SUBMITTER_ALIAS - assert submission.entity_id == ENTITY_ID - assert submission.version_number == VERSION_NUMBER - assert submission.evaluation_id == EVALUATION_ID - assert submission.name == SUBMISSION_NAME - assert submission.created_on == CREATED_ON - assert submission.team_id == TEAM_ID - assert submission.contributors == CONTRIBUTORS - assert submission.submission_status == SUBMISSION_STATUS - assert submission.entity_bundle_json == ENTITY_BUNDLE_JSON - assert submission.docker_repository_name == DOCKER_REPOSITORY_NAME - assert submission.docker_digest == DOCKER_DIGEST - - def test_fill_from_dict_minimal_data(self) -> None: - # GIVEN a minimal submission response from the REST API - # WHEN I call fill_from_dict with the minimal submission response - submission = Submission().fill_from_dict(self.get_minimal_submission_response()) - - # THEN the Submission object should be filled with required data and defaults for optional data - assert submission.id == SUBMISSION_ID - assert submission.entity_id == ENTITY_ID - assert submission.evaluation_id == EVALUATION_ID - assert submission.user_id is None - assert submission.submitter_alias is None - assert submission.version_number is None - assert submission.name is None - assert submission.created_on is None - assert submission.team_id is None - assert submission.contributors == [] - assert submission.submission_status is None - assert submission.entity_bundle_json is None - assert submission.docker_repository_name is None - assert submission.docker_digest is None - - def test_to_synapse_request_complete_data(self) -> None: - # GIVEN a submission with all optional fields set - submission = Submission( - entity_id=ENTITY_ID, - evaluation_id=EVALUATION_ID, - name=SUBMISSION_NAME, - team_id=TEAM_ID, - contributors=CONTRIBUTORS, - docker_repository_name=DOCKER_REPOSITORY_NAME, - docker_digest=DOCKER_DIGEST, - version_number=VERSION_NUMBER, - ) - - # WHEN I call to_synapse_request - request_body = submission.to_synapse_request() - - # THEN the request body should contain all fields in the correct format - assert request_body["entityId"] == ENTITY_ID - assert request_body["evaluationId"] == EVALUATION_ID - assert request_body["versionNumber"] == VERSION_NUMBER - assert request_body["name"] == SUBMISSION_NAME - assert request_body["teamId"] == TEAM_ID - assert request_body["contributors"] == CONTRIBUTORS - assert request_body["dockerRepositoryName"] == DOCKER_REPOSITORY_NAME - assert request_body["dockerDigest"] == DOCKER_DIGEST - - def test_to_synapse_request_minimal_data(self) -> None: - # GIVEN a submission with only required fields - submission = Submission( - entity_id=ENTITY_ID, - evaluation_id=EVALUATION_ID, - version_number=VERSION_NUMBER, - ) - - # WHEN I call to_synapse_request - request_body = submission.to_synapse_request() - - # THEN the request body should contain only required fields - assert request_body["entityId"] == ENTITY_ID - assert request_body["evaluationId"] == EVALUATION_ID - assert request_body["versionNumber"] == VERSION_NUMBER - assert "name" not in request_body - assert "teamId" not in request_body - assert "contributors" not in request_body - assert "dockerRepositoryName" not in request_body - assert "dockerDigest" not in request_body - - def test_to_synapse_request_missing_entity_id(self) -> None: - # GIVEN a submission without entity_id - submission = Submission(evaluation_id=EVALUATION_ID) - - # WHEN I call to_synapse_request - # THEN it should raise a ValueError - with pytest.raises(ValueError, match="missing the 'entity_id' attribute"): - submission.to_synapse_request() - - def test_to_synapse_request_missing_evaluation_id(self) -> None: - # GIVEN a submission without evaluation_id - submission = Submission(entity_id=ENTITY_ID) - - # WHEN I call to_synapse_request - # THEN it should raise a ValueError - with pytest.raises(ValueError, match="missing the 'evaluation_id' attribute"): - submission.to_synapse_request() - - @pytest.mark.asyncio - async def test_fetch_latest_entity_success(self) -> None: - # GIVEN a submission with an entity_id - submission = Submission(entity_id=ENTITY_ID, evaluation_id=EVALUATION_ID) - - # WHEN I call _fetch_latest_entity with a mocked successful response - with patch( - "synapseclient.api.entity_services.get_entity", - new_callable=AsyncMock, - return_value=self.get_example_entity_response(), - ) as mock_get_entity: - entity_info = await submission._fetch_latest_entity(synapse_client=self.syn) - - # THEN it should return the entity information - assert entity_info["id"] == ENTITY_ID - assert entity_info["etag"] == ETAG - assert entity_info["versionNumber"] == VERSION_NUMBER - mock_get_entity.assert_called_once_with( - entity_id=ENTITY_ID, synapse_client=self.syn - ) - - @pytest.mark.asyncio - async def test_fetch_latest_entity_docker_repository(self) -> None: - # GIVEN a submission with a Docker repository entity_id - submission = Submission(entity_id=ENTITY_ID, evaluation_id=EVALUATION_ID) - - # WHEN I call _fetch_latest_entity with mocked Docker repository responses - with patch( - "synapseclient.api.entity_services.get_entity", - new_callable=AsyncMock, - return_value=self.get_example_docker_entity_response(), - ) as mock_get_entity, patch( - "synapseclient.api.docker_commit_services.get_docker_tag", - new_callable=AsyncMock, - return_value=self.get_example_docker_tag_response(), - ) as mock_get_docker_tag: - entity_info = await submission._fetch_latest_entity(synapse_client=self.syn) - - # THEN it should return the entity information with latest docker tag info - assert entity_info["id"] == ENTITY_ID - assert entity_info["etag"] == ETAG - assert entity_info["repositoryName"] == "test/repository" - # Should have the latest tag information (v2.0 based on createdOn date) - assert entity_info["tag"] == "v2.0" - assert entity_info["digest"] == "sha256:latest456abc789" - assert entity_info["createdOn"] == "2024-06-01T15:30:00.000Z" - - # Verify both API functions were called - mock_get_entity.assert_called_once_with( - entity_id=ENTITY_ID, synapse_client=self.syn - ) - mock_get_docker_tag.assert_called_once_with( - entity_id=ENTITY_ID, synapse_client=self.syn - ) - - @pytest.mark.asyncio - async def test_fetch_latest_entity_docker_empty_results(self) -> None: - # GIVEN a submission with a Docker repository entity_id - submission = Submission(entity_id=ENTITY_ID, evaluation_id=EVALUATION_ID) - - # WHEN I call _fetch_latest_entity with empty docker tag results - with patch( - "synapseclient.api.entity_services.get_entity", - new_callable=AsyncMock, - return_value=self.get_example_docker_entity_response(), - ) as mock_get_entity, patch( - "synapseclient.api.docker_commit_services.get_docker_tag", - new_callable=AsyncMock, - return_value={"totalNumberOfResults": 0, "results": []}, - ) as mock_get_docker_tag: - entity_info = await submission._fetch_latest_entity(synapse_client=self.syn) - - # THEN it should return the entity information without docker tag info - assert entity_info["id"] == ENTITY_ID - assert entity_info["etag"] == ETAG - assert entity_info["repositoryName"] == "test/repository" - # Should not have docker tag fields since results were empty - assert "tag" not in entity_info - assert "digest" not in entity_info - assert "createdOn" not in entity_info - - # Verify both API functions were called - mock_get_entity.assert_called_once_with( - entity_id=ENTITY_ID, synapse_client=self.syn - ) - mock_get_docker_tag.assert_called_once_with( - entity_id=ENTITY_ID, synapse_client=self.syn - ) - assert "tag" not in entity_info - assert "digest" not in entity_info - assert "createdOn" not in entity_info - - @pytest.mark.asyncio - async def test_fetch_latest_entity_docker_complex_tag_selection(self) -> None: - # GIVEN a submission with a Docker repository with multiple tags - submission = Submission(entity_id=ENTITY_ID, evaluation_id=EVALUATION_ID) - - # WHEN I call _fetch_latest_entity with multiple docker tags with different dates - with patch( - "synapseclient.api.entity_services.get_entity", - new_callable=AsyncMock, - return_value=self.get_example_docker_entity_response(), - ) as mock_get_entity, patch( - "synapseclient.api.docker_commit_services.get_docker_tag", - new_callable=AsyncMock, - return_value=self.get_complex_docker_tag_response(), - ) as mock_get_docker_tag: - entity_info = await submission._fetch_latest_entity(synapse_client=self.syn) - - # THEN it should select the tag with the latest createdOn timestamp (v3.0) - assert entity_info["tag"] == "v3.0" - assert entity_info["digest"] == "sha256:version3" - assert entity_info["createdOn"] == "2024-08-15T12:00:00.000Z" - - # Verify both API functions were called - mock_get_entity.assert_called_once_with( - entity_id=ENTITY_ID, synapse_client=self.syn - ) - mock_get_docker_tag.assert_called_once_with( - entity_id=ENTITY_ID, synapse_client=self.syn - ) - - @pytest.mark.asyncio - async def test_fetch_latest_entity_without_entity_id(self) -> None: - # GIVEN a submission without entity_id - submission = Submission(evaluation_id=EVALUATION_ID) - - # WHEN I call _fetch_latest_entity - # THEN it should raise a ValueError - with pytest.raises( - ValueError, match="entity_id must be set to fetch entity information" - ): - await submission._fetch_latest_entity(synapse_client=self.syn) - - @pytest.mark.asyncio - async def test_fetch_latest_entity_api_error(self) -> None: - # GIVEN a submission with an entity_id - submission = Submission(entity_id=ENTITY_ID, evaluation_id=EVALUATION_ID) - - # WHEN I call _fetch_latest_entity and the API returns an error - with patch( - "synapseclient.api.entity_services.get_entity", - new_callable=AsyncMock, - side_effect=SynapseHTTPError("Entity not found"), - ) as mock_get_entity: - # THEN it should raise a LookupError with context about the original error - with pytest.raises( - LookupError, match=f"Unable to fetch entity information for {ENTITY_ID}" - ): - await submission._fetch_latest_entity(synapse_client=self.syn) - - mock_get_entity.assert_called_once_with( - entity_id=ENTITY_ID, synapse_client=self.syn - ) - - @pytest.mark.asyncio - async def test_store_async_success(self) -> None: - # GIVEN a submission with valid data - submission = Submission( - entity_id=ENTITY_ID, - evaluation_id=EVALUATION_ID, - name=SUBMISSION_NAME, - ) - - # WHEN I call store_async with mocked dependencies - with patch.object( - submission, - "_fetch_latest_entity", - new_callable=AsyncMock, - return_value=self.get_example_entity_response(), - ) as mock_fetch_entity, patch( - "synapseclient.api.evaluation_services.create_submission", - new_callable=AsyncMock, - return_value=self.get_example_submission_response(), - ) as mock_create_submission: - stored_submission = await submission.store_async(synapse_client=self.syn) - - # THEN it should fetch entity information, create the submission, and fill the object - mock_fetch_entity.assert_called_once_with(synapse_client=self.syn) - mock_create_submission.assert_called_once() - - # Check the call arguments to create_submission - call_args = mock_create_submission.call_args - request_body = call_args[0][0] - etag = call_args[0][1] - - assert request_body["entityId"] == ENTITY_ID - assert request_body["evaluationId"] == EVALUATION_ID - assert request_body["name"] == SUBMISSION_NAME - assert request_body["versionNumber"] == VERSION_NUMBER - assert etag == ETAG - - # Verify the submission is filled with response data - assert stored_submission.id == SUBMISSION_ID - assert stored_submission.entity_id == ENTITY_ID - assert stored_submission.evaluation_id == EVALUATION_ID - - @pytest.mark.asyncio - async def test_store_async_docker_repository_success(self) -> None: - # GIVEN a submission with valid data for Docker repository - submission = Submission( - entity_id=ENTITY_ID, - evaluation_id=EVALUATION_ID, - name=SUBMISSION_NAME, - ) - - # WHEN I call store_async with mocked Docker repository entity - docker_entity_with_tag = self.get_example_docker_entity_response() - docker_entity_with_tag.update( - { - "tag": "v2.0", - "digest": "sha256:latest456abc789", - "createdOn": "2024-06-01T15:30:00.000Z", - } - ) - - with patch.object( - submission, - "_fetch_latest_entity", - new_callable=AsyncMock, - return_value=docker_entity_with_tag, - ) as mock_fetch_entity, patch( - "synapseclient.api.evaluation_services.create_submission", - new_callable=AsyncMock, - return_value=self.get_example_submission_response(), - ) as mock_create_submission: - stored_submission = await submission.store_async(synapse_client=self.syn) - - # THEN it should handle Docker repository specific logic - mock_fetch_entity.assert_called_once_with(synapse_client=self.syn) - mock_create_submission.assert_called_once() - - # Verify Docker repository attributes are set correctly - assert submission.version_number == 1 # Docker repos get version 1 - assert submission.docker_repository_name == "test/repository" - assert stored_submission.docker_digest == DOCKER_DIGEST - - @pytest.mark.asyncio - async def test_store_async_with_team_data_success(self) -> None: - # GIVEN a submission with team information - submission = Submission( - entity_id=ENTITY_ID, - evaluation_id=EVALUATION_ID, - name=SUBMISSION_NAME, - team_id=TEAM_ID, - contributors=CONTRIBUTORS, - ) - - # WHEN I call store_async with mocked dependencies - with patch.object( - submission, - "_fetch_latest_entity", - new_callable=AsyncMock, - return_value=self.get_example_entity_response(), - ) as mock_fetch_entity, patch( - "synapseclient.api.evaluation_services.create_submission", - new_callable=AsyncMock, - return_value=self.get_example_submission_response(), - ) as mock_create_submission: - stored_submission = await submission.store_async(synapse_client=self.syn) - - # THEN it should preserve team information in the stored submission - mock_fetch_entity.assert_called_once_with(synapse_client=self.syn) - mock_create_submission.assert_called_once() - - # Verify team data is preserved - assert stored_submission.team_id == TEAM_ID - assert stored_submission.contributors == CONTRIBUTORS - assert stored_submission.id == SUBMISSION_ID - assert stored_submission.entity_id == ENTITY_ID - assert stored_submission.evaluation_id == EVALUATION_ID - - @pytest.mark.asyncio - async def test_store_async_missing_entity_id(self) -> None: - # GIVEN a submission without entity_id - submission = Submission(evaluation_id=EVALUATION_ID, name=SUBMISSION_NAME) - - # WHEN I call store_async - # THEN it should raise a ValueError during to_synapse_request - with pytest.raises( - ValueError, match="entity_id is required to create a submission" - ): - await submission.store_async(synapse_client=self.syn) - - @pytest.mark.asyncio - async def test_store_async_entity_fetch_failure(self) -> None: - # GIVEN a submission with valid data but entity fetch fails - submission = Submission( - entity_id=ENTITY_ID, - evaluation_id=EVALUATION_ID, - name=SUBMISSION_NAME, - ) - - # WHEN I call store_async and entity fetching fails - with patch.object( - submission, - "_fetch_latest_entity", - new_callable=AsyncMock, - side_effect=ValueError("Unable to fetch entity information"), - ) as mock_fetch_entity: - # THEN it should propagate the ValueError - with pytest.raises(ValueError, match="Unable to fetch entity information"): - await submission.store_async(synapse_client=self.syn) - - @pytest.mark.asyncio - async def test_get_async_success(self) -> None: - # GIVEN a submission with an ID - submission = Submission(id=SUBMISSION_ID) - - # WHEN I call get_async with a mocked successful response - with patch( - "synapseclient.api.evaluation_services.get_submission", - new_callable=AsyncMock, - return_value=self.get_example_submission_response(), - ) as mock_get_submission: - retrieved_submission = await submission.get_async(synapse_client=self.syn) - - # THEN it should call the API and fill the object - mock_get_submission.assert_called_once_with( - submission_id=SUBMISSION_ID, - synapse_client=self.syn, - ) - assert retrieved_submission.id == SUBMISSION_ID - assert retrieved_submission.entity_id == ENTITY_ID - assert retrieved_submission.evaluation_id == EVALUATION_ID - - @pytest.mark.asyncio - async def test_get_async_without_id(self) -> None: - # GIVEN a submission without an ID - submission = Submission(entity_id=ENTITY_ID, evaluation_id=EVALUATION_ID) - - # WHEN I call get_async - # THEN it should raise a ValueError - with pytest.raises(ValueError, match="must have an ID to get"): - await submission.get_async(synapse_client=self.syn) - - @pytest.mark.asyncio - async def test_delete_async_success(self) -> None: - # GIVEN a submission with an ID - submission = Submission(id=SUBMISSION_ID) - - # WHEN I call delete_async with mocked dependencies - with patch( - "synapseclient.api.evaluation_services.delete_submission", - new_callable=AsyncMock, - ) as mock_delete_submission, patch( - "synapseclient.Synapse.get_client", - return_value=self.syn, - ): - # Mock the logger - self.syn.logger = MagicMock() - - await submission.delete_async(synapse_client=self.syn) - - # THEN it should call the API and log the deletion - mock_delete_submission.assert_called_once_with( - submission_id=SUBMISSION_ID, - synapse_client=self.syn, - ) - self.syn.logger.info.assert_called_once_with( - f"Submission {SUBMISSION_ID} has successfully been deleted." - ) - - @pytest.mark.asyncio - async def test_delete_async_without_id(self) -> None: - # GIVEN a submission without an ID - submission = Submission(entity_id=ENTITY_ID, evaluation_id=EVALUATION_ID) - - # WHEN I call delete_async - # THEN it should raise a ValueError - with pytest.raises(ValueError, match="must have an ID to delete"): - await submission.delete_async(synapse_client=self.syn) - - @pytest.mark.asyncio - async def test_cancel_async_success(self) -> None: - # GIVEN a submission with an ID - submission = Submission(id=SUBMISSION_ID) - - # WHEN I call cancel_async with mocked dependencies - with patch( - "synapseclient.api.evaluation_services.cancel_submission", - new_callable=AsyncMock, - return_value=self.get_example_submission_response(), - ) as mock_cancel_submission, patch( - "synapseclient.Synapse.get_client", - return_value=self.syn, - ): - # Mock the logger - self.syn.logger = MagicMock() - - await submission.cancel_async(synapse_client=self.syn) - - # THEN it should call the API, log the cancellation, and update the object - mock_cancel_submission.assert_called_once_with( - submission_id=SUBMISSION_ID, - synapse_client=self.syn, - ) - self.syn.logger.info.assert_called_once_with( - f"A request to cancel Submission {SUBMISSION_ID} has been submitted." - ) - - @pytest.mark.asyncio - async def test_cancel_async_without_id(self) -> None: - # GIVEN a submission without an ID - submission = Submission(entity_id=ENTITY_ID, evaluation_id=EVALUATION_ID) - - # WHEN I call cancel_async - # THEN it should raise a ValueError - with pytest.raises(ValueError, match="must have an ID to cancel"): - await submission.cancel_async(synapse_client=self.syn) - - def test_get_evaluation_submissions(self) -> None: - # GIVEN evaluation parameters - evaluation_id = EVALUATION_ID - status = "SCORED" - - # WHEN I call get_evaluation_submissions - with patch( - "synapseclient.api.evaluation_services.get_evaluation_submissions" - ) as mock_get_submissions: - # Create an async generator function that yields submission data - async def mock_async_gen(*args, **kwargs): - submission_data = self.get_example_submission_response() - yield submission_data - - # Make the mock return our async generator when called - mock_get_submissions.side_effect = mock_async_gen - - submissions = [] - for submission in Submission.get_evaluation_submissions( - evaluation_id=evaluation_id, - status=status, - synapse_client=self.syn, - ): - submissions.append(submission) - - # THEN it should call the API with correct parameters and yield Submission objects - mock_get_submissions.assert_called_once_with( - evaluation_id=evaluation_id, - status=status, - synapse_client=self.syn, - ) - assert len(submissions) == 1 - assert isinstance(submissions[0], Submission) - assert submissions[0].id == SUBMISSION_ID - - def test_get_user_submissions(self) -> None: - # GIVEN user submission parameters - evaluation_id = EVALUATION_ID - - # WHEN I call get_user_submissions - with patch( - "synapseclient.api.evaluation_services.get_user_submissions" - ) as mock_get_user_submissions: - # Create an async generator function that yields submission data - async def mock_async_gen(*args, **kwargs): - submission_data = self.get_example_submission_response() - yield submission_data - - # Make the mock return our async generator when called - mock_get_user_submissions.side_effect = mock_async_gen - - submissions = [] - for submission in Submission.get_user_submissions( - evaluation_id=evaluation_id, - synapse_client=self.syn, - ): - submissions.append(submission) - - # THEN it should call the API with correct parameters and yield Submission objects - mock_get_user_submissions.assert_called_once_with( - evaluation_id=evaluation_id, - synapse_client=self.syn, - ) - assert len(submissions) == 1 - assert isinstance(submissions[0], Submission) - assert submissions[0].id == SUBMISSION_ID - - def test_get_submission_count(self) -> None: - # GIVEN submission count parameters - evaluation_id = EVALUATION_ID - - expected_response = 42 - - # WHEN I call get_submission_count - with patch( - "synapseclient.api.evaluation_services.get_submission_count", - new_callable=AsyncMock, - return_value=expected_response, - ) as mock_get_count: - response = Submission.get_submission_count( - evaluation_id=evaluation_id, - synapse_client=self.syn, - ) - - # THEN it should call the API with correct parameters - mock_get_count.assert_called_once_with( - evaluation_id=evaluation_id, - synapse_client=self.syn, - ) - assert response == expected_response - - def test_default_values(self) -> None: - # GIVEN a new Submission object with no parameters - submission = Submission() - - # THEN all attributes should have their default values - assert submission.id is None - assert submission.user_id is None - assert submission.submitter_alias is None - assert submission.entity_id is None - assert submission.version_number is None - assert submission.evaluation_id is None - assert submission.name is None - assert submission.created_on is None - assert submission.team_id is None - assert submission.contributors == [] - assert submission.submission_status is None - assert submission.entity_bundle_json is None - assert submission.docker_repository_name is None - assert submission.docker_digest is None - assert submission.etag is None - - def test_constructor_with_values(self) -> None: - # GIVEN specific values for submission attributes - # WHEN I create a Submission object with those values - submission = Submission( - id=SUBMISSION_ID, - entity_id=ENTITY_ID, - evaluation_id=EVALUATION_ID, - name=SUBMISSION_NAME, - team_id=TEAM_ID, - contributors=CONTRIBUTORS, - docker_repository_name=DOCKER_REPOSITORY_NAME, - docker_digest=DOCKER_DIGEST, - ) - - # THEN the object should be initialized with those values - assert submission.id == SUBMISSION_ID - assert submission.entity_id == ENTITY_ID - assert submission.evaluation_id == EVALUATION_ID - assert submission.name == SUBMISSION_NAME - assert submission.team_id == TEAM_ID - assert submission.contributors == CONTRIBUTORS - assert submission.docker_repository_name == DOCKER_REPOSITORY_NAME - assert submission.docker_digest == DOCKER_DIGEST - - def test_to_synapse_request_with_none_values(self) -> None: - # GIVEN a submission with some None values for optional fields - submission = Submission( - entity_id=ENTITY_ID, - evaluation_id=EVALUATION_ID, - name=None, # Explicitly None - team_id=None, # Explicitly None - contributors=[], # Empty list (falsy) - ) - - # WHEN I call to_synapse_request - request_body = submission.to_synapse_request() - - # THEN None and empty values should not be included - assert request_body["entityId"] == ENTITY_ID - assert request_body["evaluationId"] == EVALUATION_ID - assert "name" not in request_body - assert "teamId" not in request_body - assert "contributors" not in request_body diff --git a/tests/unit/synapseclient/models/synchronous/unit_test_submission_bundle.py b/tests/unit/synapseclient/models/synchronous/unit_test_submission_bundle.py deleted file mode 100644 index 92f61f186..000000000 --- a/tests/unit/synapseclient/models/synchronous/unit_test_submission_bundle.py +++ /dev/null @@ -1,459 +0,0 @@ -"""Unit tests for the synapseclient.models.SubmissionBundle class synchronous methods.""" - -from typing import Dict, Union -from unittest.mock import AsyncMock, patch - -import pytest - -from synapseclient import Synapse -from synapseclient.models import Submission, SubmissionBundle, SubmissionStatus - -SUBMISSION_ID = "9999999" -SUBMISSION_STATUS_ID = "9999999" -ENTITY_ID = "syn123456" -EVALUATION_ID = "9614543" -USER_ID = "123456" -ETAG = "etag_value" -MODIFIED_ON = "2023-01-01T00:00:00.000Z" -CREATED_ON = "2023-01-01T00:00:00.000Z" -STATUS = "RECEIVED" - - -class TestSubmissionBundleSync: - """Tests for the synapseclient.models.SubmissionBundle class synchronous methods.""" - - @pytest.fixture(autouse=True, scope="function") - def init_syn(self, syn: Synapse) -> None: - self.syn = syn - - def get_example_submission_dict(self) -> Dict[str, Union[str, int, Dict]]: - """Return example submission data from REST API.""" - return { - "id": SUBMISSION_ID, - "userId": USER_ID, - "submitterAlias": "test_user", - "entityId": ENTITY_ID, - "versionNumber": 1, - "name": "Test Submission", - "createdOn": CREATED_ON, - "evaluationId": EVALUATION_ID, - "entityBundle": { - "entity": { - "id": ENTITY_ID, - "name": "test_entity", - "concreteType": "org.sagebionetworks.repo.model.FileEntity", - }, - "entityType": "org.sagebionetworks.repo.model.FileEntity", - }, - } - - def get_example_submission_status_dict( - self, - ) -> Dict[str, Union[str, int, bool, Dict]]: - """Return example submission status data from REST API.""" - return { - "id": SUBMISSION_STATUS_ID, - "etag": ETAG, - "modifiedOn": MODIFIED_ON, - "status": STATUS, - "entityId": ENTITY_ID, - "versionNumber": 1, - "statusVersion": 1, - "canCancel": False, - "cancelRequested": False, - "submissionAnnotations": {"score": [85.5], "feedback": ["Good work!"]}, - } - - def get_example_submission_bundle_dict(self) -> Dict[str, Dict]: - """Return example submission bundle data from REST API.""" - return { - "submission": self.get_example_submission_dict(), - "submissionStatus": self.get_example_submission_status_dict(), - } - - def get_example_submission_bundle_minimal_dict(self) -> Dict[str, Dict]: - """Return example minimal submission bundle data from REST API.""" - return { - "submission": { - "id": SUBMISSION_ID, - "entityId": ENTITY_ID, - "evaluationId": EVALUATION_ID, - }, - "submissionStatus": None, - } - - def test_init_submission_bundle(self) -> None: - """Test creating a SubmissionBundle with basic attributes.""" - # GIVEN submission and submission status objects - submission = Submission( - id=SUBMISSION_ID, - entity_id=ENTITY_ID, - evaluation_id=EVALUATION_ID, - ) - submission_status = SubmissionStatus( - id=SUBMISSION_STATUS_ID, - status=STATUS, - entity_id=ENTITY_ID, - ) - - # WHEN I create a SubmissionBundle object - bundle = SubmissionBundle( - submission=submission, - submission_status=submission_status, - ) - - # THEN the SubmissionBundle should have the expected attributes - assert bundle.submission == submission - assert bundle.submission_status == submission_status - assert bundle.submission.id == SUBMISSION_ID - assert bundle.submission_status.id == SUBMISSION_STATUS_ID - - def test_init_submission_bundle_empty(self) -> None: - """Test creating an empty SubmissionBundle.""" - # WHEN I create an empty SubmissionBundle object - bundle = SubmissionBundle() - - # THEN the SubmissionBundle should have None attributes - assert bundle.submission is None - assert bundle.submission_status is None - - def test_fill_from_dict_complete(self) -> None: - """Test filling a SubmissionBundle from complete REST API response.""" - # GIVEN a complete submission bundle response - bundle_data = self.get_example_submission_bundle_dict() - - # WHEN I fill a SubmissionBundle from the response - bundle = SubmissionBundle().fill_from_dict(bundle_data) - - # THEN all fields should be populated correctly - assert bundle.submission is not None - assert bundle.submission_status is not None - - # Check submission fields - assert bundle.submission.id == SUBMISSION_ID - assert bundle.submission.entity_id == ENTITY_ID - assert bundle.submission.evaluation_id == EVALUATION_ID - assert bundle.submission.user_id == USER_ID - - # Check submission status fields - assert bundle.submission_status.id == SUBMISSION_STATUS_ID - assert bundle.submission_status.status == STATUS - assert bundle.submission_status.entity_id == ENTITY_ID - - # Check submission annotations - assert "score" in bundle.submission_status.submission_annotations - assert bundle.submission_status.submission_annotations["score"] == [85.5] - - def test_fill_from_dict_minimal(self) -> None: - """Test filling a SubmissionBundle from minimal REST API response.""" - # GIVEN a minimal submission bundle response - bundle_data = self.get_example_submission_bundle_minimal_dict() - - # WHEN I fill a SubmissionBundle from the response - bundle = SubmissionBundle().fill_from_dict(bundle_data) - - # THEN submission should be populated but submission_status should be None - assert bundle.submission is not None - assert bundle.submission_status is None - - # Check submission fields - assert bundle.submission.id == SUBMISSION_ID - assert bundle.submission.entity_id == ENTITY_ID - assert bundle.submission.evaluation_id == EVALUATION_ID - - def test_fill_from_dict_no_submission(self) -> None: - """Test filling a SubmissionBundle with no submission data.""" - # GIVEN a bundle response with no submission - bundle_data = { - "submission": None, - "submissionStatus": self.get_example_submission_status_dict(), - } - - # WHEN I fill a SubmissionBundle from the response - bundle = SubmissionBundle().fill_from_dict(bundle_data) - - # THEN submission should be None but submission_status should be populated - assert bundle.submission is None - assert bundle.submission_status is not None - assert bundle.submission_status.id == SUBMISSION_STATUS_ID - assert bundle.submission_status.status == STATUS - - def test_get_evaluation_submission_bundles(self) -> None: - """Test getting submission bundles for an evaluation using sync method.""" - # GIVEN mock response data - mock_response = { - "results": [ - { - "submission": { - "id": "123", - "entityId": ENTITY_ID, - "evaluationId": EVALUATION_ID, - "userId": USER_ID, - }, - "submissionStatus": { - "id": "123", - "status": "RECEIVED", - "entityId": ENTITY_ID, - }, - }, - { - "submission": { - "id": "456", - "entityId": ENTITY_ID, - "evaluationId": EVALUATION_ID, - "userId": USER_ID, - }, - "submissionStatus": { - "id": "456", - "status": "SCORED", - "entityId": ENTITY_ID, - }, - }, - ] - } - - # WHEN I call get_evaluation_submission_bundles (sync method) - with patch( - "synapseclient.api.evaluation_services.get_evaluation_submission_bundles" - ) as mock_get_bundles: - # Create an async generator function that yields bundle data - async def mock_async_gen(*args, **kwargs): - for bundle_data in mock_response["results"]: - yield bundle_data - - # Make the mock return our async generator when called - mock_get_bundles.side_effect = mock_async_gen - - result = list( - SubmissionBundle.get_evaluation_submission_bundles( - evaluation_id=EVALUATION_ID, - status="RECEIVED", - synapse_client=self.syn, - ) - ) - - # THEN the service should be called with correct parameters - mock_get_bundles.assert_called_once_with( - evaluation_id=EVALUATION_ID, - status="RECEIVED", - synapse_client=self.syn, - ) - - # AND the result should contain SubmissionBundle objects - assert len(result) == 2 - assert all(isinstance(bundle, SubmissionBundle) for bundle in result) - - # Check first bundle - assert result[0].submission is not None - assert result[0].submission.id == "123" - assert result[0].submission_status is not None - assert result[0].submission_status.id == "123" - assert result[0].submission_status.status == "RECEIVED" - - # Check second bundle - assert result[1].submission is not None - assert result[1].submission.id == "456" - assert result[1].submission_status is not None - assert result[1].submission_status.id == "456" - assert result[1].submission_status.status == "SCORED" - - def test_get_evaluation_submission_bundles_empty_response(self) -> None: - """Test getting submission bundles with empty response using sync method.""" - # GIVEN empty mock response - mock_response = {"results": []} - - # WHEN I call get_evaluation_submission_bundles (sync method) - with patch( - "synapseclient.api.evaluation_services.get_evaluation_submission_bundles" - ) as mock_get_bundles: - # Create an async generator function that yields no data - async def mock_async_gen(*args, **kwargs): - return - yield - - # Make the mock return our async generator when called - mock_get_bundles.side_effect = mock_async_gen - - result = list( - SubmissionBundle.get_evaluation_submission_bundles( - evaluation_id=EVALUATION_ID, - synapse_client=self.syn, - ) - ) - - # THEN the service should be called - mock_get_bundles.assert_called_once_with( - evaluation_id=EVALUATION_ID, - status=None, - synapse_client=self.syn, - ) - - # AND the result should be an empty list - assert len(result) == 0 - - def test_get_user_submission_bundles(self) -> None: - """Test getting user submission bundles using sync method.""" - # GIVEN mock response data - mock_response = { - "results": [ - { - "submission": { - "id": "789", - "entityId": ENTITY_ID, - "evaluationId": EVALUATION_ID, - "userId": USER_ID, - "name": "User Submission 1", - }, - "submissionStatus": { - "id": "789", - "status": "VALIDATED", - "entityId": ENTITY_ID, - }, - }, - ] - } - - # WHEN I call get_user_submission_bundles (sync method) - with patch( - "synapseclient.api.evaluation_services.get_user_submission_bundles" - ) as mock_get_user_bundles: - # Create an async generator function that yields bundle data - async def mock_async_gen(*args, **kwargs): - for bundle_data in mock_response["results"]: - yield bundle_data - - # Make the mock return our async generator when called - mock_get_user_bundles.side_effect = mock_async_gen - - result = list( - SubmissionBundle.get_user_submission_bundles( - evaluation_id=EVALUATION_ID, - synapse_client=self.syn, - ) - ) - - # THEN the service should be called with correct parameters - mock_get_user_bundles.assert_called_once_with( - evaluation_id=EVALUATION_ID, - synapse_client=self.syn, - ) - - # AND the result should contain SubmissionBundle objects - assert len(result) == 1 - assert isinstance(result[0], SubmissionBundle) - - # Check bundle contents - assert result[0].submission is not None - assert result[0].submission.id == "789" - assert result[0].submission.name == "User Submission 1" - assert result[0].submission_status is not None - assert result[0].submission_status.id == "789" - assert result[0].submission_status.status == "VALIDATED" - - def test_get_user_submission_bundles_default_params(self) -> None: - """Test getting user submission bundles with default parameters using sync method.""" - # GIVEN mock response - mock_response = {"results": []} - - # WHEN I call get_user_submission_bundles with defaults (sync method) - with patch( - "synapseclient.api.evaluation_services.get_user_submission_bundles" - ) as mock_get_user_bundles: - # Create an async generator function that yields no data - async def mock_async_gen(*args, **kwargs): - return - yield # This will never execute - - # Make the mock return our async generator when called - mock_get_user_bundles.side_effect = mock_async_gen - - result = list( - SubmissionBundle.get_user_submission_bundles( - evaluation_id=EVALUATION_ID, - synapse_client=self.syn, - ) - ) - - # THEN the service should be called with default parameters - mock_get_user_bundles.assert_called_once_with( - evaluation_id=EVALUATION_ID, - synapse_client=self.syn, - ) - - # AND the result should be empty - assert len(result) == 0 - - def test_dataclass_equality(self) -> None: - """Test dataclass equality comparison.""" - # GIVEN two SubmissionBundle objects with the same data - submission = Submission(id=SUBMISSION_ID, entity_id=ENTITY_ID) - status = SubmissionStatus(id=SUBMISSION_STATUS_ID, status=STATUS) - - bundle1 = SubmissionBundle(submission=submission, submission_status=status) - bundle2 = SubmissionBundle(submission=submission, submission_status=status) - - # THEN they should be equal - assert bundle1 == bundle2 - - # WHEN I modify one of them - bundle2.submission_status = SubmissionStatus(id="different", status="DIFFERENT") - - # THEN they should not be equal - assert bundle1 != bundle2 - - def test_dataclass_equality_with_none(self) -> None: - """Test dataclass equality with None values.""" - # GIVEN two SubmissionBundle objects with None values - bundle1 = SubmissionBundle(submission=None, submission_status=None) - bundle2 = SubmissionBundle(submission=None, submission_status=None) - - # THEN they should be equal - assert bundle1 == bundle2 - - # WHEN I add a submission to one - bundle2.submission = Submission(id=SUBMISSION_ID) - - # THEN they should not be equal - assert bundle1 != bundle2 - - def test_repr_and_str(self) -> None: - """Test string representation of SubmissionBundle.""" - # GIVEN a SubmissionBundle with some data - submission = Submission(id=SUBMISSION_ID, entity_id=ENTITY_ID) - status = SubmissionStatus(id=SUBMISSION_STATUS_ID, status=STATUS) - bundle = SubmissionBundle(submission=submission, submission_status=status) - - # WHEN I get the string representation - repr_str = repr(bundle) - str_str = str(bundle) - - # THEN it should contain relevant information - assert "SubmissionBundle" in repr_str - assert SUBMISSION_ID in repr_str - assert SUBMISSION_STATUS_ID in repr_str - - # AND str should be the same as repr for dataclasses - assert str_str == repr_str - - def test_repr_with_none_values(self) -> None: - """Test string representation with None values.""" - # GIVEN a SubmissionBundle with None values - bundle = SubmissionBundle(submission=None, submission_status=None) - - # WHEN I get the string representation - repr_str = repr(bundle) - - # THEN it should show None values - assert "SubmissionBundle" in repr_str - assert "submission=None" in repr_str - assert "submission_status=None" in repr_str - - def test_protocol_implementation(self) -> None: - """Test that SubmissionBundle implements the synchronous protocol correctly.""" - # THEN it should have all the required synchronous methods - assert hasattr(SubmissionBundle, "get_evaluation_submission_bundles") - assert hasattr(SubmissionBundle, "get_user_submission_bundles") - - # AND the methods should be callable - assert callable(SubmissionBundle.get_evaluation_submission_bundles) - assert callable(SubmissionBundle.get_user_submission_bundles) diff --git a/tests/unit/synapseclient/models/synchronous/unit_test_submission_status.py b/tests/unit/synapseclient/models/synchronous/unit_test_submission_status.py deleted file mode 100644 index 5243dc6ab..000000000 --- a/tests/unit/synapseclient/models/synchronous/unit_test_submission_status.py +++ /dev/null @@ -1,556 +0,0 @@ -"""Unit tests for the synapseclient.models.SubmissionStatus class synchronous methods.""" - -from typing import Dict, Union -from unittest.mock import AsyncMock, patch - -import pytest - -from synapseclient import Synapse -from synapseclient.models import SubmissionStatus - -SUBMISSION_STATUS_ID = "9999999" -ENTITY_ID = "syn123456" -EVALUATION_ID = "9614543" -ETAG = "etag_value" -MODIFIED_ON = "2023-01-01T00:00:00.000Z" -STATUS = "RECEIVED" -SCORE = 85.5 -REPORT = "Test report" -VERSION_NUMBER = 1 -STATUS_VERSION = 1 -CAN_CANCEL = False -CANCEL_REQUESTED = False -PRIVATE_STATUS_ANNOTATIONS = True - - -class TestSubmissionStatusSync: - """Tests for the synapseclient.models.SubmissionStatus class synchronous methods.""" - - @pytest.fixture(autouse=True, scope="function") - def init_syn(self, syn: Synapse) -> None: - self.syn = syn - - def get_example_submission_status_dict( - self, - ) -> Dict[str, Union[str, int, bool, Dict]]: - """Return example submission status data from REST API.""" - return { - "id": SUBMISSION_STATUS_ID, - "etag": ETAG, - "modifiedOn": MODIFIED_ON, - "status": STATUS, - "score": SCORE, - "report": REPORT, - "entityId": ENTITY_ID, - "versionNumber": VERSION_NUMBER, - "statusVersion": STATUS_VERSION, - "canCancel": CAN_CANCEL, - "cancelRequested": CANCEL_REQUESTED, - "annotations": { - "objectId": SUBMISSION_STATUS_ID, - "scopeId": EVALUATION_ID, - "stringAnnos": [ - { - "key": "internal_note", - "isPrivate": True, - "value": "This is internal", - } - ], - "doubleAnnos": [ - {"key": "validation_score", "isPrivate": True, "value": 95.0} - ], - "longAnnos": [], - }, - "submissionAnnotations": {"feedback": ["Great work!"], "score": [92.5]}, - } - - def get_example_submission_dict(self) -> Dict[str, str]: - """Return example submission data from REST API.""" - return { - "id": SUBMISSION_STATUS_ID, - "evaluationId": EVALUATION_ID, - "entityId": ENTITY_ID, - "versionNumber": VERSION_NUMBER, - "userId": "123456", - "submitterAlias": "test_user", - "createdOn": "2023-01-01T00:00:00.000Z", - } - - def test_init_submission_status(self) -> None: - """Test creating a SubmissionStatus with basic attributes.""" - # WHEN I create a SubmissionStatus object - submission_status = SubmissionStatus( - id=SUBMISSION_STATUS_ID, - status=STATUS, - entity_id=ENTITY_ID, - ) - - # THEN the SubmissionStatus should have the expected attributes - assert submission_status.id == SUBMISSION_STATUS_ID - assert submission_status.status == STATUS - assert submission_status.entity_id == ENTITY_ID - assert submission_status.can_cancel is False # default value - assert submission_status.cancel_requested is False # default value - assert submission_status.private_status_annotations is True # default value - - def test_fill_from_dict(self) -> None: - """Test filling a SubmissionStatus from a REST API response.""" - # GIVEN an example submission status response - submission_status_data = self.get_example_submission_status_dict() - - # WHEN I fill a SubmissionStatus from the response - submission_status = SubmissionStatus().fill_from_dict(submission_status_data) - - # THEN all fields should be populated correctly - assert submission_status.id == SUBMISSION_STATUS_ID - assert submission_status.etag == ETAG - assert submission_status.modified_on == MODIFIED_ON - assert submission_status.status == STATUS - assert submission_status.score == SCORE - assert submission_status.report == REPORT - assert submission_status.entity_id == ENTITY_ID - assert submission_status.version_number == VERSION_NUMBER - assert submission_status.status_version == STATUS_VERSION - assert submission_status.can_cancel is CAN_CANCEL - assert submission_status.cancel_requested is CANCEL_REQUESTED - - # Check annotations - assert submission_status.annotations is not None - assert "objectId" in submission_status.annotations - assert "scopeId" in submission_status.annotations - assert "stringAnnos" in submission_status.annotations - assert "doubleAnnos" in submission_status.annotations - - # Check submission annotations - assert "feedback" in submission_status.submission_annotations - assert "score" in submission_status.submission_annotations - assert submission_status.submission_annotations["feedback"] == ["Great work!"] - assert submission_status.submission_annotations["score"] == [92.5] - - def test_fill_from_dict_minimal(self) -> None: - """Test filling a SubmissionStatus from minimal REST API response.""" - # GIVEN a minimal submission status response - minimal_data = {"id": SUBMISSION_STATUS_ID, "status": STATUS} - - # WHEN I fill a SubmissionStatus from the response - submission_status = SubmissionStatus().fill_from_dict(minimal_data) - - # THEN basic fields should be populated - assert submission_status.id == SUBMISSION_STATUS_ID - assert submission_status.status == STATUS - # AND optional fields should have default values - assert submission_status.etag is None - assert submission_status.can_cancel is False - assert submission_status.cancel_requested is False - - def test_get(self) -> None: - """Test retrieving a SubmissionStatus by ID using sync method.""" - # GIVEN a SubmissionStatus with an ID - submission_status = SubmissionStatus(id=SUBMISSION_STATUS_ID) - - # WHEN I call get (sync method) - with patch( - "synapseclient.api.evaluation_services.get_submission_status", - new_callable=AsyncMock, - return_value=self.get_example_submission_status_dict(), - ) as mock_get_status: - result = submission_status.get(synapse_client=self.syn) - - # THEN the submission status should be retrieved - mock_get_status.assert_called_once_with( - submission_id=SUBMISSION_STATUS_ID, synapse_client=self.syn - ) - - # AND the result should have the expected data - assert result.id == SUBMISSION_STATUS_ID - assert result.status == STATUS - - def test_get_without_id(self) -> None: - """Test that getting a SubmissionStatus without ID raises ValueError.""" - # GIVEN a SubmissionStatus without an ID - submission_status = SubmissionStatus() - - # WHEN I call get - # THEN it should raise a ValueError - with pytest.raises( - ValueError, match="The submission status must have an ID to get" - ): - submission_status.get(synapse_client=self.syn) - - def test_store(self) -> None: - """Test storing a SubmissionStatus using sync method.""" - # GIVEN a SubmissionStatus with required attributes - submission_status = SubmissionStatus( - id=SUBMISSION_STATUS_ID, - etag=ETAG, - status_version=STATUS_VERSION, - status="SCORED", - ) - submission_status._set_last_persistent_instance() - - # AND I modify the status - submission_status.status = "VALIDATED" - - # WHEN I call store (sync method) - with patch( - "synapseclient.api.evaluation_services.update_submission_status", - new_callable=AsyncMock, - return_value=self.get_example_submission_status_dict(), - ) as mock_update: - result = submission_status.store(synapse_client=self.syn) - - # THEN the submission status should be updated - mock_update.assert_called_once() - call_args = mock_update.call_args - assert call_args.kwargs["submission_id"] == SUBMISSION_STATUS_ID - assert call_args.kwargs["synapse_client"] == self.syn - - # AND the result should have updated data - assert result.id == SUBMISSION_STATUS_ID - assert result.status == STATUS # from mock response - - def test_store_without_id(self) -> None: - """Test that storing a SubmissionStatus without ID raises ValueError.""" - # GIVEN a SubmissionStatus without an ID - submission_status = SubmissionStatus(status="SCORED") - - # WHEN I call store - # THEN it should raise a ValueError - with pytest.raises( - ValueError, match="The submission status must have an ID to update" - ): - submission_status.store(synapse_client=self.syn) - - def test_store_without_changes(self) -> None: - """Test storing a SubmissionStatus without changes.""" - # GIVEN a SubmissionStatus that hasn't been modified - submission_status = SubmissionStatus( - id=SUBMISSION_STATUS_ID, - etag=ETAG, - status_version=STATUS_VERSION, - status=STATUS, - ) - submission_status._set_last_persistent_instance() - - # WHEN I call store without making changes - result = submission_status.store(synapse_client=self.syn) - - # THEN it should return the same instance (no update sent to Synapse) - assert result is submission_status - - def test_to_synapse_request_missing_id(self) -> None: - """Test to_synapse_request with missing ID.""" - # GIVEN a SubmissionStatus without an ID - submission_status = SubmissionStatus() - - # WHEN I call to_synapse_request - # THEN it should raise a ValueError - with pytest.raises(ValueError, match="missing the 'id' attribute"): - submission_status.to_synapse_request(synapse_client=self.syn) - - def test_to_synapse_request_missing_etag(self) -> None: - """Test to_synapse_request with missing etag.""" - # GIVEN a SubmissionStatus with ID but no etag - submission_status = SubmissionStatus(id=SUBMISSION_STATUS_ID) - - # WHEN I call to_synapse_request - # THEN it should raise a ValueError - with pytest.raises(ValueError, match="missing the 'etag' attribute"): - submission_status.to_synapse_request(synapse_client=self.syn) - - def test_to_synapse_request_missing_status_version(self) -> None: - """Test to_synapse_request with missing status_version.""" - # GIVEN a SubmissionStatus with ID and etag but no status_version - submission_status = SubmissionStatus(id=SUBMISSION_STATUS_ID, etag=ETAG) - - # WHEN I call to_synapse_request - # THEN it should raise a ValueError - with pytest.raises(ValueError, match="missing the 'status_version' attribute"): - submission_status.to_synapse_request(synapse_client=self.syn) - - def test_to_synapse_request_valid(self) -> None: - """Test to_synapse_request with valid attributes.""" - # GIVEN a SubmissionStatus with all required attributes - submission_status = SubmissionStatus( - id=SUBMISSION_STATUS_ID, - etag=ETAG, - status_version=STATUS_VERSION, - status="SCORED", - submission_annotations={"score": 85.5}, - annotations={"internal_note": "test"}, - ) - - # WHEN I call to_synapse_request - request_body = submission_status.to_synapse_request(synapse_client=self.syn) - - # THEN the request should have the required fields - assert request_body["id"] == SUBMISSION_STATUS_ID - assert request_body["etag"] == ETAG - assert request_body["statusVersion"] == STATUS_VERSION - assert request_body["status"] == "SCORED" - assert "submissionAnnotations" in request_body - assert "annotations" in request_body - - def test_has_changed_property_new_instance(self) -> None: - """Test has_changed property for a new instance.""" - # GIVEN a new SubmissionStatus instance - submission_status = SubmissionStatus(id=SUBMISSION_STATUS_ID) - - # THEN has_changed should be True (no persistent instance) - assert submission_status.has_changed is True - - def test_has_changed_property_after_get(self) -> None: - """Test has_changed property after retrieving from Synapse.""" - # GIVEN a SubmissionStatus that was retrieved (has persistent instance) - submission_status = SubmissionStatus(id=SUBMISSION_STATUS_ID, status=STATUS) - submission_status._set_last_persistent_instance() - - # THEN has_changed should be False - assert submission_status.has_changed is False - - # WHEN I modify a field - submission_status.status = "VALIDATED" - - # THEN has_changed should be True - assert submission_status.has_changed is True - - def test_has_changed_property_annotations(self) -> None: - """Test has_changed property with annotation changes.""" - # GIVEN a SubmissionStatus with annotations - submission_status = SubmissionStatus( - id=SUBMISSION_STATUS_ID, - annotations={"original": "value"}, - submission_annotations={"score": 85.0}, - ) - submission_status._set_last_persistent_instance() - - # THEN has_changed should be False initially - assert submission_status.has_changed is False - - # WHEN I modify annotations - submission_status.annotations = {"modified": "value"} - - # THEN has_changed should be True - assert submission_status.has_changed is True - - # WHEN I reset annotations to original and modify submission_annotations - submission_status.annotations = {"original": "value"} - submission_status.submission_annotations = {"score": 90.0} - - # THEN has_changed should still be True - assert submission_status.has_changed is True - - def test_get_all_submission_statuses(self) -> None: - """Test getting all submission statuses using sync method.""" - # GIVEN mock response data - mock_response = { - "results": [ - { - "id": "123", - "status": "RECEIVED", - "entityId": ENTITY_ID, - "evaluationId": EVALUATION_ID, - }, - { - "id": "456", - "status": "SCORED", - "entityId": ENTITY_ID, - "evaluationId": EVALUATION_ID, - }, - ] - } - - # WHEN I call get_all_submission_statuses (sync method) - with patch( - "synapseclient.api.evaluation_services.get_all_submission_statuses", - new_callable=AsyncMock, - return_value=mock_response, - ) as mock_get_all: - result = SubmissionStatus.get_all_submission_statuses( - evaluation_id=EVALUATION_ID, - status="RECEIVED", - limit=50, - offset=0, - synapse_client=self.syn, - ) - - # THEN the service should be called with correct parameters - mock_get_all.assert_called_once_with( - evaluation_id=EVALUATION_ID, - status="RECEIVED", - limit=50, - offset=0, - synapse_client=self.syn, - ) - - # AND the result should contain SubmissionStatus objects - assert len(result) == 2 - assert all(isinstance(status, SubmissionStatus) for status in result) - assert result[0].id == "123" - assert result[0].status == "RECEIVED" - assert result[1].id == "456" - assert result[1].status == "SCORED" - - def test_batch_update_submission_statuses(self) -> None: - """Test batch updating submission statuses using sync method.""" - # GIVEN a list of SubmissionStatus objects - statuses = [ - SubmissionStatus( - id="123", etag="etag1", status_version=1, status="VALIDATED" - ), - SubmissionStatus(id="456", etag="etag2", status_version=1, status="SCORED"), - ] - - # AND mock response - mock_response = {"batchToken": "token123"} - - # WHEN I call batch_update_submission_statuses (sync method) - with patch( - "synapseclient.api.evaluation_services.batch_update_submission_statuses", - new_callable=AsyncMock, - return_value=mock_response, - ) as mock_batch_update: - result = SubmissionStatus.batch_update_submission_statuses( - evaluation_id=EVALUATION_ID, - statuses=statuses, - is_first_batch=True, - is_last_batch=True, - synapse_client=self.syn, - ) - - # THEN the service should be called with correct parameters - mock_batch_update.assert_called_once() - call_args = mock_batch_update.call_args - assert call_args.kwargs["evaluation_id"] == EVALUATION_ID - assert call_args.kwargs["synapse_client"] == self.syn - - # Check request body structure - request_body = call_args.kwargs["request_body"] - assert request_body["isFirstBatch"] is True - assert request_body["isLastBatch"] is True - assert "statuses" in request_body - assert len(request_body["statuses"]) == 2 - - # AND the result should be the mock response - assert result == mock_response - - def test_batch_update_with_batch_token(self) -> None: - """Test batch update with batch token for subsequent batches.""" - # GIVEN a list of SubmissionStatus objects and a batch token - statuses = [ - SubmissionStatus( - id="123", etag="etag1", status_version=1, status="VALIDATED" - ) - ] - batch_token = "previous_batch_token" - - # WHEN I call batch_update_submission_statuses with a batch token - with patch( - "synapseclient.api.evaluation_services.batch_update_submission_statuses", - new_callable=AsyncMock, - return_value={}, - ) as mock_batch_update: - SubmissionStatus.batch_update_submission_statuses( - evaluation_id=EVALUATION_ID, - statuses=statuses, - is_first_batch=False, - is_last_batch=True, - batch_token=batch_token, - synapse_client=self.syn, - ) - - # THEN the batch token should be included in the request - call_args = mock_batch_update.call_args - request_body = call_args.kwargs["request_body"] - assert request_body["batchToken"] == batch_token - assert request_body["isFirstBatch"] is False - - def test_set_last_persistent_instance(self) -> None: - """Test setting the last persistent instance.""" - # GIVEN a SubmissionStatus - submission_status = SubmissionStatus( - id=SUBMISSION_STATUS_ID, - status=STATUS, - annotations={"test": "value"}, - ) - - # WHEN I set the last persistent instance - submission_status._set_last_persistent_instance() - - # THEN the persistent instance should be set - assert submission_status._last_persistent_instance is not None - assert submission_status._last_persistent_instance.id == SUBMISSION_STATUS_ID - assert submission_status._last_persistent_instance.status == STATUS - assert submission_status._last_persistent_instance.annotations == { - "test": "value" - } - - # AND modifying the current instance shouldn't affect the persistent one - submission_status.status = "MODIFIED" - assert submission_status._last_persistent_instance.status == STATUS - - def test_dataclass_equality(self) -> None: - """Test dataclass equality comparison.""" - # GIVEN two SubmissionStatus objects with the same data - status1 = SubmissionStatus( - id=SUBMISSION_STATUS_ID, - status=STATUS, - entity_id=ENTITY_ID, - ) - status2 = SubmissionStatus( - id=SUBMISSION_STATUS_ID, - status=STATUS, - entity_id=ENTITY_ID, - ) - - # THEN they should be equal - assert status1 == status2 - - # WHEN I modify one of them - status2.status = "DIFFERENT" - - # THEN they should not be equal - assert status1 != status2 - - def test_dataclass_fields_excluded_from_comparison(self) -> None: - """Test that certain fields are excluded from comparison.""" - # GIVEN two SubmissionStatus objects that differ only in comparison-excluded fields - status1 = SubmissionStatus( - id=SUBMISSION_STATUS_ID, - status=STATUS, - etag="etag1", - modified_on="2023-01-01", - cancel_requested=False, - ) - status2 = SubmissionStatus( - id=SUBMISSION_STATUS_ID, - status=STATUS, - etag="etag2", # different etag - modified_on="2023-01-02", # different modified_on - cancel_requested=True, # different cancel_requested - ) - - # THEN they should still be equal (these fields are excluded from comparison) - assert status1 == status2 - - def test_repr_and_str(self) -> None: - """Test string representation of SubmissionStatus.""" - # GIVEN a SubmissionStatus with some data - submission_status = SubmissionStatus( - id=SUBMISSION_STATUS_ID, - status=STATUS, - entity_id=ENTITY_ID, - ) - - # WHEN I get the string representation - repr_str = repr(submission_status) - str_str = str(submission_status) - - # THEN it should contain the relevant information - assert SUBMISSION_STATUS_ID in repr_str - assert STATUS in repr_str - assert ENTITY_ID in repr_str - assert "SubmissionStatus" in repr_str - - # AND str should be the same as repr for dataclasses - assert str_str == repr_str diff --git a/tests/unit/synapseclient/models/synchronous/unit_test_team.py b/tests/unit/synapseclient/models/synchronous/unit_test_team.py deleted file mode 100644 index 4f5192b86..000000000 --- a/tests/unit/synapseclient/models/synchronous/unit_test_team.py +++ /dev/null @@ -1,279 +0,0 @@ -"""Tests for the synapseclient.models.team module.""" - -from unittest.mock import patch - -import pytest - -from synapseclient import Synapse -from synapseclient.models.team import Team, TeamMember, TeamMembershipStatus -from synapseclient.models.user import UserGroupHeader - - -class TestTeamMember: - """Tests for the TeamMember class.""" - - def test_fill_from_dict(self) -> None: - # GIVEN a blank TeamMember - team_member = TeamMember() - # WHEN I fill it with a dictionary - team_member.fill_from_dict( - synapse_team_member={"teamId": 1, "member": {"ownerId": 2}, "isAdmin": True} - ) - # THEN I expect all fields to be set - assert team_member.team_id == 1 - assert team_member.member.owner_id == 2 - assert team_member.is_admin is True - - -class TestTeamMembershipStatus: - """Tests for the TeamMembershipStatus class.""" - - def test_fill_from_dict(self) -> None: - # GIVEN a blank TeamMembershipStatus - status = TeamMembershipStatus() - # WHEN I fill it with a dictionary - status.fill_from_dict( - { - "teamId": "123", - "userId": "456", - "isMember": False, - "hasOpenInvitation": True, - "hasOpenRequest": False, - "canJoin": False, - "membershipApprovalRequired": True, - "hasUnmetAccessRequirement": False, - "canSendEmail": True, - } - ) - # THEN I expect all fields to be set - assert status.team_id == "123" - assert status.user_id == "456" - assert status.is_member is False - assert status.has_open_invitation is True - assert status.has_open_request is False - assert status.can_join is False - assert status.membership_approval_required is True - assert status.has_unmet_access_requirement is False - assert status.can_send_email is True - - -class TestTeam: - """Tests for the Team class.""" - - @pytest.fixture(autouse=True, scope="function") - def init(self, syn: Synapse) -> None: - self.syn = syn - self.MESSAGE = "test message" - self.USER = "test_user" - self.DESCRIPTION = "test description" - self.NAME = "test_team" - self.TIMESTAMP = "2020-01-01T00:00:00.000Z" - self.invite_response = { - "id": "1", - "teamId": "2", - "inviteeId": "3", - "message": self.MESSAGE, - "createdOn": "2000-01-01T00:00:00.000Z", - "createdBy": "4", - } - - def test_fill_from_dict(self) -> None: - # GIVEN a blank Team - team = Team() - # WHEN I fill it with a dictionary - team.fill_from_dict( - synapse_team={ - "id": "1", - "name": self.NAME, - "description": self.DESCRIPTION, - "icon": "test_file_handle_id", - "canPublicJoin": True, - "canRequestMembership": True, - "etag": "11111111-1111-1111-1111-111111111111", - "createdOn": self.TIMESTAMP, - "modifiedOn": self.TIMESTAMP, - "createdBy": self.USER, - "modifiedBy": self.USER, - } - ) - # THEN I expect all fields to be set - assert team.id == 1 - assert team.name == self.NAME - assert team.description == self.DESCRIPTION - assert team.icon == "test_file_handle_id" - assert team.can_public_join is True - assert team.can_request_membership is True - assert team.etag == "11111111-1111-1111-1111-111111111111" - assert team.created_on == self.TIMESTAMP - assert team.modified_on == self.TIMESTAMP - assert team.created_by == self.USER - assert team.modified_by == self.USER - - def test_create(self) -> None: - with patch( - "synapseclient.models.team.create_team", - return_value={"id": 1, "name": self.NAME, "description": self.DESCRIPTION}, - ) as patch_create_team: - # GIVEN a team object - team = Team(name=self.NAME, description=self.DESCRIPTION) - # WHEN I create the team - team = team.create(synapse_client=self.syn) - # THEN I expect the patched method to be called as expected - patch_create_team.assert_called_once_with( - name=self.NAME, - description=self.DESCRIPTION, - icon=None, - can_public_join=False, - can_request_membership=True, - synapse_client=self.syn, - ) - # AND I expect the original team to be returned - assert team.id == 1 - assert team.name == self.NAME - assert team.description == self.DESCRIPTION - assert team.icon is None - assert team.can_public_join is False - assert team.can_request_membership is True - - def test_delete(self) -> None: - with patch( - "synapseclient.models.team.delete_team", - return_value=None, - ) as patch_delete_team: - # GIVEN a team object - team = Team(id=1, name=self.NAME) - # WHEN I delete the team - team.delete(synapse_client=self.syn) - # THEN I expect the patched method to be called as expected - patch_delete_team.assert_called_once_with(id=1, synapse_client=self.syn) - - def test_get_with_id(self) -> None: - with patch( - "synapseclient.models.team.get_team", - return_value={"id": 1, "name": self.NAME, "description": self.DESCRIPTION}, - ) as patch_from_id: - # GIVEN a team object with an id - team = Team(id=1) - # WHEN I retrieve a team using its id - team = team.get(synapse_client=self.syn) - # THEN I expect the patched method to be called as expected - patch_from_id.assert_called_once_with(id=1, synapse_client=self.syn) - # AND I expect the intended team to be returned - assert team.id == 1 - assert team.name == self.NAME - assert team.description == self.DESCRIPTION - - def test_get_with_name(self) -> None: - with patch( - "synapseclient.models.team.get_team", - return_value={"id": 1, "name": self.NAME, "description": self.DESCRIPTION}, - ) as patch_from_name: - # GIVEN a team object with a name - team = Team(name=self.NAME) - # WHEN I retrieve a team using its name - team = team.get(synapse_client=self.syn) - # THEN I expect the patched method to be called as expected - patch_from_name.assert_called_once_with( - id=self.NAME, synapse_client=self.syn - ) - # AND I expect the intended team to be returned - assert team.id == 1 - assert team.name == self.NAME - assert team.description == self.DESCRIPTION - - def test_get_with_no_id_or_name(self) -> None: - # GIVEN a team object with no id or name - team = Team() - # WHEN I retrieve a team - with pytest.raises(ValueError, match="Team must have either an id or a name"): - # THEN I expect an error to be raised - team.get(synapse_client=self.syn) - - def test_from_id(self) -> None: - with patch( - "synapseclient.models.team.get_team", - return_value={"id": 1, "name": self.NAME, "description": self.DESCRIPTION}, - ) as patch_get_team: - # WHEN I retrieve a team using its id - team = Team.from_id(id=1, synapse_client=self.syn) - # THEN I expect the patched method to be called as expected - patch_get_team.assert_called_once_with(id=1, synapse_client=self.syn) - # AND I expect the intended team to be returned - assert team.id == 1 - assert team.name == self.NAME - assert team.description == self.DESCRIPTION - - def test_from_name(self) -> None: - with patch( - "synapseclient.models.team.get_team", - return_value={"id": 1, "name": self.NAME, "description": self.DESCRIPTION}, - ) as patch_get_team: - # WHEN I retrieve a team using its name - team = Team.from_name(name=self.NAME, synapse_client=self.syn) - # THEN I expect the patched method to be called as expected - patch_get_team.assert_called_once_with( - id=self.NAME, synapse_client=self.syn - ) - # AND I expect the intended team to be returned - assert team.id == 1 - assert team.name == self.NAME - assert team.description == self.DESCRIPTION - - def test_members(self) -> None: - with patch( - "synapseclient.models.team.get_team_members", - return_value=[{"teamId": 1, "member": {}}], - ) as patch_get_team_members: - # GIVEN a team object - team = Team(id=1) - # WHEN I get the team members - team_members = team.members(synapse_client=self.syn) - # THEN I expect the patched method to be called as expected - patch_get_team_members.assert_called_once_with( - team=1, synapse_client=self.syn - ) - # AND I expect the expected team members to be returned - assert len(team_members) == 1 - assert team_members[0].team_id == 1 - assert isinstance(team_members[0].member, UserGroupHeader) - - def test_invite(self) -> None: - with patch( - "synapseclient.models.team.invite_to_team", - return_value=self.invite_response, - ) as patch_invite_team_member: - # GIVEN a team object - team = Team(id=1) - # WHEN I invite a user to the team - invite = team.invite( - user=self.USER, - message=self.MESSAGE, - synapse_client=self.syn, - ) - # THEN I expect the patched method to be called as expected - patch_invite_team_member.assert_called_once_with( - team=1, - user=self.USER, - message=self.MESSAGE, - force=True, - synapse_client=self.syn, - ) - # AND I expect the expected invite to be returned - assert invite == self.invite_response - - def test_open_invitations(self) -> None: - with patch( - "synapseclient.models.team.get_team_open_invitations", - return_value=[self.invite_response], - ) as patch_get_open_team_invitations: - # GIVEN a team object - team = Team(id=1) - # WHEN I get the open team invitations - open_team_invitations = team.open_invitations(synapse_client=self.syn) - # THEN I expect the patched method to be called as expected - patch_get_open_team_invitations.assert_called_once_with( - team=1, synapse_client=self.syn - ) - # AND I expect the expected invitations to be returned - assert len(open_team_invitations) == 1 - assert open_team_invitations[0] == self.invite_response diff --git a/tests/unit/synapseclient/models/synchronous/unit_test_user.py b/tests/unit/synapseclient/models/synchronous/unit_test_user.py deleted file mode 100644 index 38dedcc13..000000000 --- a/tests/unit/synapseclient/models/synchronous/unit_test_user.py +++ /dev/null @@ -1,356 +0,0 @@ -"""Tests for the synapseclient.models.user module.""" - -from unittest.mock import patch - -import pytest - -from synapseclient import Synapse -from synapseclient.models import UserPreference, UserProfile -from synapseclient.team import UserProfile as Synapse_UserProfile - -FIRST_NAME = "John" -LAST_NAME = "Doe" -USER_NAME = "johndoe" -EMAIL = "john.doe@sagebase.org" -ETAG = "some_value" -OPEN_IDS = ["aa222", "bb333"] -BOGUS_URL = "https://sagebase.org" -SUMMARY = "some summary" -POSITION = "some position" -LOCATION = "some location" -INDUSTRY = "some industry" -COMPANY = "some company" -PROFILE_PICTURE_FILE_HANDLE_ID = "some_file_handle_id" -TEAM_NAME = "some team name" -PREFERENCE_1 = "false_value" -PREFFERENCE_2 = "true_value" -CREATED_ON = "2020-01-01T00:00:00.000Z" - - -class TestUser: - """Tests for the UserGroupHeader class.""" - - @pytest.fixture(autouse=True, scope="function") - def init_syn(self, syn: Synapse) -> None: - self.syn = syn - - def get_example_synapse_user_profile(self) -> Synapse_UserProfile: - return Synapse_UserProfile( - ownerId=123, - etag=ETAG, - firstName=FIRST_NAME, - lastName=LAST_NAME, - emails=[EMAIL], - openIds=OPEN_IDS, - userName=USER_NAME, - rStudioUrl=BOGUS_URL, - summary=SUMMARY, - position=POSITION, - location=LOCATION, - industry=INDUSTRY, - company=COMPANY, - profilePicureFileHandleId=PROFILE_PICTURE_FILE_HANDLE_ID, - url=BOGUS_URL, - teamName=TEAM_NAME, - notificationSettings={ - "sendEmailNotifications": True, - "markEmailedMessagesAsRead": False, - }, - preferences=[ - {"name": PREFERENCE_1, "value": False}, - {"name": PREFFERENCE_2, "value": True}, - ], - createdOn=CREATED_ON, - ) - - def test_fill_from_dict(self) -> None: - # GIVEN a blank user profile - user_profile = UserProfile() - - # WHEN we fill it from a dictionary - user_profile.fill_from_dict(self.get_example_synapse_user_profile()) - - # THEN the user profile should be filled - assert user_profile.id == 123 - assert user_profile.etag == ETAG - assert user_profile.first_name == FIRST_NAME - assert user_profile.last_name == LAST_NAME - assert user_profile.emails == [EMAIL] - assert user_profile.open_ids == OPEN_IDS - assert user_profile.username == USER_NAME - assert user_profile.r_studio_url == BOGUS_URL - assert user_profile.summary == SUMMARY - assert user_profile.position == POSITION - assert user_profile.location == LOCATION - assert user_profile.industry == INDUSTRY - assert user_profile.company == COMPANY - assert ( - user_profile.profile_picure_file_handle_id == PROFILE_PICTURE_FILE_HANDLE_ID - ) - assert user_profile.url == BOGUS_URL - assert user_profile.team_name == TEAM_NAME - assert user_profile.send_email_notifications - assert not user_profile.mark_emailed_messages_as_read - assert user_profile.preferences == [ - UserPreference(name=PREFERENCE_1, value=False), - UserPreference(name=PREFFERENCE_2, value=True), - ] - assert user_profile.created_on == CREATED_ON - - def test_get_id(self) -> None: - # GIVEN a user profile - user_profile = UserProfile(id=123) - - # WHEN we get the ID - with patch( - "synapseclient.models.user.get_user_profile_by_id", - return_value=(self.get_example_synapse_user_profile()), - ) as mocked_client_call: - profile = user_profile.get(synapse_client=self.syn) - - # THEN we should call the method with this data - mocked_client_call.assert_called_once_with(id=123, synapse_client=self.syn) - - # AND we should get the profile back - assert profile.id == 123 - assert profile.etag == ETAG - assert profile.first_name == FIRST_NAME - assert profile.last_name == LAST_NAME - assert profile.emails == [EMAIL] - assert profile.open_ids == OPEN_IDS - assert profile.username == USER_NAME - assert profile.r_studio_url == BOGUS_URL - assert profile.summary == SUMMARY - assert profile.position == POSITION - assert profile.location == LOCATION - assert profile.industry == INDUSTRY - assert profile.company == COMPANY - assert ( - profile.profile_picure_file_handle_id == PROFILE_PICTURE_FILE_HANDLE_ID - ) - assert profile.url == BOGUS_URL - assert profile.team_name == TEAM_NAME - assert profile.send_email_notifications - assert not profile.mark_emailed_messages_as_read - assert profile.preferences == [ - UserPreference(name=PREFERENCE_1, value=False), - UserPreference(name=PREFFERENCE_2, value=True), - ] - assert profile.created_on == CREATED_ON - - def test_get_username(self) -> None: - # GIVEN a user profile - user_profile = UserProfile(username=USER_NAME) - - # WHEN we get the ID - with patch( - "synapseclient.models.user.get_user_profile_by_username", - return_value=(self.get_example_synapse_user_profile()), - ) as mocked_client_call: - profile = user_profile.get(synapse_client=self.syn) - - # THEN we should call the method with this data - mocked_client_call.assert_called_once_with( - username=USER_NAME, synapse_client=self.syn - ) - - # AND we should get the profile back - assert profile.id == 123 - assert profile.etag == ETAG - assert profile.first_name == FIRST_NAME - assert profile.last_name == LAST_NAME - assert profile.emails == [EMAIL] - assert profile.open_ids == OPEN_IDS - assert profile.username == USER_NAME - assert profile.r_studio_url == BOGUS_URL - assert profile.summary == SUMMARY - assert profile.position == POSITION - assert profile.location == LOCATION - assert profile.industry == INDUSTRY - assert profile.company == COMPANY - assert ( - profile.profile_picure_file_handle_id == PROFILE_PICTURE_FILE_HANDLE_ID - ) - assert profile.url == BOGUS_URL - assert profile.team_name == TEAM_NAME - assert profile.send_email_notifications - assert not profile.mark_emailed_messages_as_read - assert profile.preferences == [ - UserPreference(name=PREFERENCE_1, value=False), - UserPreference(name=PREFFERENCE_2, value=True), - ] - assert profile.created_on == CREATED_ON - - def test_get_neither(self) -> None: - # GIVEN a blank user profile - user_profile = UserProfile() - - # WHEN we get the ID - with patch( - "synapseclient.models.user.get_user_profile_by_username", - return_value=(self.get_example_synapse_user_profile()), - ) as mocked_client_call: - profile = user_profile.get(synapse_client=self.syn) - - # THEN we should call the method with this data - mocked_client_call.assert_called_once_with(synapse_client=self.syn) - - # AND we should get the profile back - assert profile.id == 123 - assert profile.etag == ETAG - assert profile.first_name == FIRST_NAME - assert profile.last_name == LAST_NAME - assert profile.emails == [EMAIL] - assert profile.open_ids == OPEN_IDS - assert profile.username == USER_NAME - assert profile.r_studio_url == BOGUS_URL - assert profile.summary == SUMMARY - assert profile.position == POSITION - assert profile.location == LOCATION - assert profile.industry == INDUSTRY - assert profile.company == COMPANY - assert ( - profile.profile_picure_file_handle_id == PROFILE_PICTURE_FILE_HANDLE_ID - ) - assert profile.url == BOGUS_URL - assert profile.team_name == TEAM_NAME - assert profile.send_email_notifications - assert not profile.mark_emailed_messages_as_read - assert profile.preferences == [ - UserPreference(name=PREFERENCE_1, value=False), - UserPreference(name=PREFFERENCE_2, value=True), - ] - assert profile.created_on == CREATED_ON - - def test_get_from_id(self) -> None: - # GIVEN no user profile - - # WHEN we get from ID - with patch( - "synapseclient.models.user.get_user_profile_by_id", - return_value=(self.get_example_synapse_user_profile()), - ) as mocked_client_call: - profile = UserProfile.from_id(user_id=123, synapse_client=self.syn) - - # THEN we should call the method with this data - mocked_client_call.assert_called_once_with(id=123, synapse_client=self.syn) - - # AND we should get the profile back - assert profile.id == 123 - assert profile.etag == ETAG - assert profile.first_name == FIRST_NAME - assert profile.last_name == LAST_NAME - assert profile.emails == [EMAIL] - assert profile.open_ids == OPEN_IDS - assert profile.username == USER_NAME - assert profile.r_studio_url == BOGUS_URL - assert profile.summary == SUMMARY - assert profile.position == POSITION - assert profile.location == LOCATION - assert profile.industry == INDUSTRY - assert profile.company == COMPANY - assert ( - profile.profile_picure_file_handle_id == PROFILE_PICTURE_FILE_HANDLE_ID - ) - assert profile.url == BOGUS_URL - assert profile.team_name == TEAM_NAME - assert profile.send_email_notifications - assert not profile.mark_emailed_messages_as_read - assert profile.preferences == [ - UserPreference(name=PREFERENCE_1, value=False), - UserPreference(name=PREFFERENCE_2, value=True), - ] - assert profile.created_on == CREATED_ON - - def test_get_from_username(self) -> None: - # GIVEN no user profile - - # WHEN we get from ID - with patch( - "synapseclient.models.user.get_user_profile_by_username", - return_value=(self.get_example_synapse_user_profile()), - ) as mocked_client_call: - profile = UserProfile.from_username( - username=USER_NAME, synapse_client=self.syn - ) - - # THEN we should call the method with this data - mocked_client_call.assert_called_once_with( - username=USER_NAME, synapse_client=self.syn - ) - - # AND we should get the profile back - assert profile.id == 123 - assert profile.etag == ETAG - assert profile.first_name == FIRST_NAME - assert profile.last_name == LAST_NAME - assert profile.emails == [EMAIL] - assert profile.open_ids == OPEN_IDS - assert profile.username == USER_NAME - assert profile.r_studio_url == BOGUS_URL - assert profile.summary == SUMMARY - assert profile.position == POSITION - assert profile.location == LOCATION - assert profile.industry == INDUSTRY - assert profile.company == COMPANY - assert ( - profile.profile_picure_file_handle_id == PROFILE_PICTURE_FILE_HANDLE_ID - ) - assert profile.url == BOGUS_URL - assert profile.team_name == TEAM_NAME - assert profile.send_email_notifications - assert not profile.mark_emailed_messages_as_read - assert profile.preferences == [ - UserPreference(name=PREFERENCE_1, value=False), - UserPreference(name=PREFFERENCE_2, value=True), - ] - assert profile.created_on == CREATED_ON - - def test_is_certified_id(self) -> None: - # GIVEN a user profile - user_profile = UserProfile(id=123) - - # WHEN we check if the user is certified - with patch( - "synapseclient.models.user.is_user_certified", - return_value=True, - ) as mocked_client_call: - is_certified = user_profile.is_certified(synapse_client=self.syn) - - # THEN we should call the method with this data - mocked_client_call.assert_called_once_with( - user=123, synapse_client=self.syn - ) - - # AND we should get the profile back - assert is_certified - - def test_is_certified_username(self) -> None: - # GIVEN a user profile - user_profile = UserProfile(username=USER_NAME) - - # WHEN we check if the user is certified - with patch( - "synapseclient.models.user.is_user_certified", - return_value=True, - ) as mocked_client_call: - is_certified = user_profile.is_certified(synapse_client=self.syn) - - # THEN we should call the method with this data - mocked_client_call.assert_called_once_with( - user=USER_NAME, synapse_client=self.syn - ) - - # AND we should get the profile back - assert is_certified - - def test_is_certified_neither(self) -> None: - # GIVEN a user profile - user_profile = UserProfile() - - # WHEN we check if the user is certified - with pytest.raises(ValueError) as e: - user_profile.is_certified(synapse_client=self.syn) - - # THEN we should get an error - assert str(e.value) == "Must specify either id or username" diff --git a/tests/unit/synapseclient/models/synchronous/unit_test_wiki.py b/tests/unit/synapseclient/models/synchronous/unit_test_wiki.py deleted file mode 100644 index d84be07ed..000000000 --- a/tests/unit/synapseclient/models/synchronous/unit_test_wiki.py +++ /dev/null @@ -1,1678 +0,0 @@ -"""Synchronous tests for the synapseclient.models.wiki classes.""" -import copy -import os -from typing import Any, AsyncGenerator, Dict, Generator, List -from unittest.mock import Mock, call, mock_open, patch - -import pytest - -from synapseclient import Synapse -from synapseclient.core.exceptions import SynapseHTTPError -from synapseclient.models.wiki import ( - PresignedUrlInfo, - WikiHeader, - WikiHistorySnapshot, - WikiOrderHint, - WikiPage, -) - - -class TestWikiOrderHint: - """Tests for the WikiOrderHint class.""" - - @pytest.fixture(autouse=True, scope="function") - def init_syn(self, syn: Synapse) -> None: - self.syn = syn - - # Construct a WikiOrderHint object - order_hint = WikiOrderHint( - owner_id="syn123", - owner_object_type="org.sagebionetworks.repo.model.Project", - id_list=["wiki1", "wiki2", "wiki3"], - etag="etag123", - ) - - api_response = { - "ownerId": "syn123", - "ownerObjectType": "org.sagebionetworks.repo.model.Project", - "idList": ["wiki1", "wiki2", "wiki3"], - "etag": "etag123", - } - - def test_fill_from_dict(self) -> None: - # WHEN I call `fill_from_dict` with the API response - result = self.order_hint.fill_from_dict(self.api_response) - - # THEN the WikiOrderHint object should be filled with the example data - assert result == self.order_hint - - def test_to_synapse_request(self): - # WHEN I call `to_synapse_request` on an initialized order hint - results = self.order_hint.to_synapse_request() - - # THEN the request should contain the correct data - assert results == self.api_response - - def test_to_synapse_request_with_none_values(self) -> None: - # GIVEN a WikiOrderHint object with None values - order_hint = WikiOrderHint( - owner_id="syn123", - owner_object_type=None, - id_list=[], - etag=None, - ) - - # WHEN I call `to_synapse_request` - results = order_hint.to_synapse_request() - - # THEN the request should not contain None values - assert results == {"ownerId": "syn123", "idList": []} - - def test_store_success(self) -> None: - # GIVEN a mock response - with patch( - "synapseclient.models.wiki.put_wiki_order_hint", - return_value=self.api_response, - ) as mocked_put: - # WHEN I call `store` - results = self.order_hint.store(synapse_client=self.syn) - - # THEN the API should be called with correct parameters - mocked_put.assert_called_once_with( - owner_id=self.order_hint.owner_id, - request=self.order_hint.to_synapse_request(), - synapse_client=self.syn, - ) - - # AND the result should be updated with the response - assert results == self.order_hint - - def test_store_missing_owner_id(self) -> None: - # GIVEN a WikiOrderHint object without owner_id - order_hint = WikiOrderHint( - owner_object_type="org.sagebionetworks.repo.model.Project", - id_list=["wiki1", "wiki2"], - ) - - # WHEN I call `store` - # THEN it should raise ValueError - with patch( - "synapseclient.models.wiki.put_wiki_order_hint", - return_value=self.api_response, - ) as mocked_put, pytest.raises( - ValueError, match="Must provide owner_id to store wiki order hint." - ): - order_hint.store(synapse_client=self.syn) - # THEN the API should not be called - mocked_put.assert_not_called() - - def test_get_success(self) -> None: - # WHEN I call `get` - with patch( - "synapseclient.models.wiki.get_wiki_order_hint", - return_value=self.api_response, - ) as mocked_get: - results = self.order_hint.get(synapse_client=self.syn) - - # THEN the API should be called with correct parameters - mocked_get.assert_called_once_with( - owner_id="syn123", - synapse_client=self.syn, - ) - - # AND the result should be filled with the response - assert results == self.order_hint - - def test_get_missing_owner_id(self) -> None: - # GIVEN a WikiOrderHint object without owner_id - self.order_hint.owner_id = None - # WHEN I call `get` - # THEN it should raise ValueError - with patch( - "synapseclient.models.wiki.get_wiki_order_hint" - ) as mocked_get, pytest.raises( - ValueError, match="Must provide owner_id to get wiki order hint." - ): - self.order_hint.get(synapse_client=self.syn) - # THEN the API should not be called - mocked_get.assert_not_called() - - -class TestWikiHistorySnapshot: - """Tests for the WikiHistorySnapshot class.""" - - @pytest.fixture(autouse=True, scope="function") - def init_syn(self, syn: Synapse) -> None: - self.syn = syn - - # Construct a WikiHistorySnapshot object - history_snapshot = WikiHistorySnapshot( - version="1", - modified_on="2023-01-01T00:00:00.000Z", - modified_by="12345", - ) - - # Construct an API response - api_response = { - "version": "1", - "modifiedOn": "2023-01-01T00:00:00.000Z", - "modifiedBy": "12345", - } - - def test_fill_from_dict(self) -> None: - # WHEN I call `fill_from_dict` with the API response - results = self.history_snapshot.fill_from_dict(self.api_response) - - # THEN the WikiHistorySnapshot object should be filled with the example data - assert results == self.history_snapshot - - def test_get_success(self) -> None: - # GIVEN mock responses - async def mock_responses() -> AsyncGenerator[Dict[str, Any], None]: - yield { - "version": 1, - "modifiedOn": "2023-01-01T00:00:00.000Z", - "modifiedBy": "12345", - } - yield { - "version": 2, - "modifiedOn": "2023-01-02T00:00:00.000Z", - "modifiedBy": "12345", - } - yield { - "version": 3, - "modifiedOn": "2023-01-03T00:00:00.000Z", - "modifiedBy": "12345", - } - - # Create an async generator function - async def mock_async_generator(): - async for item in mock_responses(): - yield item - - # WHEN I call `get` - with patch( - "synapseclient.models.wiki.get_wiki_history", - return_value=mock_async_generator(), - ) as mocked_get: - results = [] - for item in WikiHistorySnapshot().get( - owner_id="syn123", - id="wiki1", - offset=0, - limit=20, - synapse_client=self.syn, - ): - results.append(item) - # THEN the API should be called with correct parameters - mocked_get.assert_called_once_with( - owner_id="syn123", - wiki_id="wiki1", - offset=0, - limit=20, - synapse_client=self.syn, - ) - - history_snapshot_list = [ - WikiHistorySnapshot( - version=1, - modified_on="2023-01-01T00:00:00.000Z", - modified_by="12345", - ), - WikiHistorySnapshot( - version=2, - modified_on="2023-01-02T00:00:00.000Z", - modified_by="12345", - ), - WikiHistorySnapshot( - version=3, - modified_on="2023-01-03T00:00:00.000Z", - modified_by="12345", - ), - ] - # AND the results should contain the expected data - assert results == history_snapshot_list - - def test_get_missing_owner_id(self) -> None: - # WHEN I call `get` - with patch( - "synapseclient.models.wiki.get_wiki_history" - ) as mocked_get, pytest.raises( - ValueError, match="Must provide owner_id to get wiki history." - ): - # Need to iterate to trigger validation - list( - WikiHistorySnapshot.get( - owner_id=None, - id="wiki1", - synapse_client=self.syn, - ) - ) - # THEN the API should not be called - mocked_get.assert_not_called() - - def test_get_missing_id(self) -> None: - # WHEN I call `get` - with patch( - "synapseclient.models.wiki.get_wiki_history" - ) as mocked_get, pytest.raises( - ValueError, match="Must provide id to get wiki history." - ): - # Need to iterate to trigger validation - list( - WikiHistorySnapshot.get( - owner_id="syn123", - id=None, - synapse_client=self.syn, - ) - ) - # THEN the API should not be called - mocked_get.assert_not_called() - - -class TestWikiHeader: - """Tests for the WikiHeader class.""" - - @pytest.fixture(autouse=True, scope="function") - def init_syn(self, syn: Synapse) -> None: - self.syn = syn - - wiki_header = WikiHeader( - id="wiki1", - title="Test Wiki", - parent_id="1234", - ) - - api_response = { - "id": "wiki1", - "title": "Test Wiki", - "parentId": "1234", - } - - def test_fill_from_dict(self) -> None: - # WHEN I call `fill_from_dict` with the example data - results = self.wiki_header.fill_from_dict(self.api_response) - - # THEN the WikiHeader object should be filled with the example data - assert results == self.wiki_header - - def test_get_success(self) -> None: - # GIVEN mock responses - mock_responses = [ - { - "id": "wiki1", - "title": "Test Wiki", - "parentId": "1234", - }, - { - "id": "wiki2", - "title": "Test Wiki 2", - "parentId": "1234", - }, - ] - - # Create an async generator function - async def mock_async_generator(*args, **kwargs): - for item in mock_responses: - yield item - - with patch( - "synapseclient.models.wiki.get_wiki_header_tree", - return_value=mock_async_generator(), - ) as mocked_get: - results = list( - WikiHeader.get( - owner_id="syn123", - synapse_client=self.syn, - offset=0, - limit=20, - ) - ) - - # THEN the API should be called with correct parameters - mocked_get.assert_called_once_with( - owner_id="syn123", - offset=0, - limit=20, - synapse_client=self.syn, - ) - - # AND the results should contain the expected data - wiki_header_list = [ - WikiHeader(id="wiki1", title="Test Wiki", parent_id="1234"), - WikiHeader(id="wiki2", title="Test Wiki 2", parent_id="1234"), - ] - assert results == wiki_header_list - - def test_get_missing_owner_id(self) -> None: - # WHEN I call `get` - # THEN it should raise ValueError - with patch( - "synapseclient.models.wiki.get_wiki_header_tree" - ) as mocked_get, pytest.raises( - ValueError, match="Must provide owner_id to get wiki header tree." - ): - # Need to iterate to trigger validation - list(WikiHeader.get(owner_id=None, synapse_client=self.syn)) - # THEN the API should not be called - mocked_get.assert_not_called() - - -class TestWikiPage: - """Tests for the WikiPage class.""" - - @pytest.fixture(autouse=True, scope="function") - def init_syn(self, syn: Synapse) -> None: - self.syn = syn - - wiki_page = WikiPage( - id="wiki1", - etag="etag123", - title="Test Wiki Page", - parent_id="parent_wiki", - markdown="# Test markdown text", - attachments=["test_1.txt", "test_2.txt"], - owner_id="syn123", - created_on="2023-01-01T00:00:00.000Z", - created_by="12345", - modified_on="2023-01-02T00:00:00.000Z", - modified_by="12345", - wiki_version="0", - markdown_file_handle_id=None, - attachment_file_handle_ids=[], - ) - - api_response = { - "id": "wiki1", - "etag": "etag123", - "title": "Test Wiki Page", - "parentWikiId": "parent_wiki", - "createdOn": "2023-01-01T00:00:00.000Z", - "createdBy": "12345", - "modifiedOn": "2023-01-02T00:00:00.000Z", - "modifiedBy": "12345", - "markdownFileHandleId": None, - "attachmentFileHandleIds": [], - } - - def get_fresh_wiki_page(self) -> WikiPage: - """Helper method to get a fresh copy of the wiki_page for tests that need to modify it.""" - return copy.deepcopy(self.wiki_page) - - def test_fill_from_dict(self) -> None: - # WHEN I call `fill_from_dict` with the example data - results = self.wiki_page.fill_from_dict(self.api_response) - - # THEN the WikiPage object should be filled with the example data - assert results == self.wiki_page - - def test_to_synapse_request_delete_none_keys(self) -> None: - # WHEN I call `to_synapse_request` - results = self.wiki_page.to_synapse_request() - # delete none keys for expected response - expected_results = copy.deepcopy(self.api_response) - expected_results.pop("markdownFileHandleId", None) - expected_results.pop("ownerId", None) - expected_results["attachments"] = self.wiki_page.attachments - expected_results["markdown"] = self.wiki_page.markdown - expected_results["wikiVersion"] = self.wiki_page.wiki_version - # THEN the request should contain the correct data - assert results == expected_results - - def test_to_gzip_file_with_string_content(self) -> None: - self.syn.cache.cache_root_dir = "temp_cache_dir" - - # WHEN I call `_to_gzip_file` with a markdown string - with patch("os.path.isfile", return_value=False), patch( - "builtins.open", mock_open(read_data=b"test content") - ), patch("gzip.open", mock_open()), patch("os.path.exists", return_value=True): - file_path = self.wiki_page._to_gzip_file(self.wiki_page.markdown, self.syn) - - # THEN the content should be written to a gzipped file - assert file_path == os.path.join( - self.syn.cache.cache_root_dir, - "wiki_content", - "wiki_markdown_Test Wiki Page.md.gz", - ) - - def test_to_gzip_file_with_gzipped_file(self) -> None: - with patch("os.path.isfile", return_value=True): - self.syn.cache.cache_root_dir = "temp_cache_dir" - markdown_file_path = "wiki_markdown_Test Wiki Page.md.gz" - # WHEN I call `_to_gzip_file` with a gzipped file - file_path = self.wiki_page._to_gzip_file(markdown_file_path, self.syn) - - # THEN the filepath should be the same as the input - assert file_path == markdown_file_path - - def test_to_gzip_file_with_non_gzipped_file(self) -> None: - self.syn.cache.cache_root_dir = "temp_cache_dir" - test_file_path = os.path.join("file_path", "test.txt") - - # WHEN I call `_to_gzip_file` with a file path - with patch("os.path.isfile", return_value=True), patch( - "builtins.open", mock_open(read_data=b"test content") - ), patch("gzip.open", mock_open()), patch("os.path.exists", return_value=True): - file_path = self.wiki_page._to_gzip_file( - os.path.join(test_file_path, "test.txt"), self.syn - ) - - # THEN the file should be processed - assert file_path == os.path.join( - self.syn.cache.cache_root_dir, "wiki_content", "test.txt.gz" - ) - - def test_to_gzip_file_with_invalid_content(self) -> None: - # WHEN I call `_to_gzip_file` with invalid content type - # THEN it should raise SyntaxError - with pytest.raises(SyntaxError, match="Expected a string, got int"): - self.wiki_page._to_gzip_file(123, self.syn) - - def test_unzip_gzipped_file_with_markdown(self) -> None: - self.syn.cache.cache_root_dir = "temp_cache_dir" - gzipped_file_path = os.path.join(self.syn.cache.cache_root_dir, "test.md.gz") - expected_unzipped_file_path = os.path.join( - self.syn.cache.cache_root_dir, "test.md" - ) - markdown_content = "# Test Markdown\n\nThis is a test." - markdown_content_bytes = markdown_content.encode("utf-8") - - # WHEN I call `_unzip_gzipped_file` with a binary file - with patch("gzip.open") as mock_gzip_open, patch( - "builtins.open" - ) as mock_open_file, patch("pprint.pp") as mock_pprint: - mock_gzip_open.return_value.__enter__.return_value.read.return_value = ( - markdown_content_bytes - ) - unzipped_file_path = self.wiki_page.unzip_gzipped_file(gzipped_file_path) - - # THEN the file should be unzipped correctly - mock_gzip_open.assert_called_once_with(gzipped_file_path, "rb") - mock_pprint.assert_called_once_with(markdown_content) - mock_open_file.assert_called_once_with( - expected_unzipped_file_path, "wt", encoding="utf-8" - ) - mock_open_file.return_value.__enter__.return_value.write.assert_called_once_with( - markdown_content - ) - assert unzipped_file_path == expected_unzipped_file_path - - def test_unzip_gzipped_file_with_binary_file(self) -> None: - self.syn.cache.cache_root_dir = "temp_cache_dir" - gzipped_file_path = os.path.join(self.syn.cache.cache_root_dir, "test.bin.gz") - expected_unzipped_file_path = os.path.join( - self.syn.cache.cache_root_dir, "test.bin" - ) - binary_content = b"\x00\x01\x02\x03\xff\xfe\xfd" - - # WHEN I call `_unzip_gzipped_file` with a binary file - with patch("gzip.open") as mock_gzip_open, patch( - "builtins.open" - ) as mock_open_file, patch("pprint.pp") as mock_pprint: - mock_gzip_open.return_value.__enter__.return_value.read.return_value = ( - binary_content - ) - unzipped_file_path = self.wiki_page.unzip_gzipped_file(gzipped_file_path) - - # THEN the file should be unzipped correctly - mock_gzip_open.assert_called_once_with(gzipped_file_path, "rb") - mock_pprint.assert_not_called() - mock_open_file.assert_called_once_with(unzipped_file_path, "wb") - mock_open_file.return_value.__enter__.return_value.write.assert_called_once_with( - binary_content - ) - assert unzipped_file_path == expected_unzipped_file_path - - def test_unzip_gzipped_file_with_text_file(self) -> None: - self.syn.cache.cache_root_dir = "temp_cache_dir" - gzipped_file_path = os.path.join(self.syn.cache.cache_root_dir, "test.txt.gz") - expected_unzipped_file_path = os.path.join( - self.syn.cache.cache_root_dir, "test.txt" - ) - text_content = "This is plain text content." - text_content_bytes = text_content.encode("utf-8") - - # WHEN I call `_unzip_gzipped_file` with a text file - with patch("gzip.open") as mock_gzip_open, patch( - "builtins.open" - ) as mock_open_file, patch( - "synapseclient.models.wiki.pprint.pp" - ) as mock_pprint: - mock_gzip_open.return_value.__enter__.return_value.read.return_value = ( - text_content_bytes - ) - unzipped_file_path = self.wiki_page.unzip_gzipped_file(gzipped_file_path) - - # THEN the file should be unzipped correctly - mock_gzip_open.assert_called_once_with(gzipped_file_path, "rb") - mock_pprint.assert_not_called() - mock_open_file.assert_called_once_with( - unzipped_file_path, "wt", encoding="utf-8" - ) - mock_open_file.return_value.__enter__.return_value.write.assert_called_once_with( - text_content - ) - assert unzipped_file_path == expected_unzipped_file_path - - def test_get_file_size_success(self) -> None: - # GIVEN a filehandle dictionary - filehandle_dict = { - "list": [ - {"fileName": "test1.txt", "contentSize": "100"}, - {"fileName": "test2.txt", "contentSize": "200"}, - ] - } - - # WHEN I call `_get_file_size` - results = WikiPage._get_file_size(filehandle_dict, "test1.txt") - - # THEN the result should be the content size - assert results == "100" - - def test_get_file_size_file_not_found(self) -> None: - # GIVEN a filehandle dictionary - filehandle_dict = { - "list": [ - {"fileName": "test1.txt", "contentSize": "100"}, - {"fileName": "test2.txt", "contentSize": "200"}, - ] - } - - # WHEN I call `_get_file_size` with a non-existent file - # THEN it should raise ValueError - with pytest.raises( - ValueError, match="File nonexistent.txt not found in filehandle_dict" - ): - WikiPage._get_file_size(filehandle_dict, "nonexistent.txt") - - @pytest.mark.parametrize( - "file_name,expected", - [ - ("test.txt", "test%2Etxt"), - ("test.txt.gz", "test%2Etxt%2Egz"), - ("test_1.txt", "test%5F1%2Etxt"), - ], - ids=[ - "file_name_with_one_dot", - "file_name_with_multiple_dots", - "file_name_with_dot_underscore", - ], - ) - def test_reformat_attachment_file_name(self, file_name: str, expected: str) -> None: - # WHEN I call `_reformat_attachment_file_name` with a file name - result = WikiPage.reformat_attachment_file_name(file_name) - # THEN the result should be the reformatted file name - assert result == expected - - @pytest.mark.parametrize( - "file_name,expected", - [ - ("test.png", False), - ("test.txt.gz", False), - ("test.txt", True), - ], - ) - def test_should_gzip_file_with_image_file( - self, file_name: str, expected: bool - ) -> None: - # WHEN I call `_should_gzip_file` with an image file - result = WikiPage._should_gzip_file(file_name) - # THEN the result should be False - assert result == expected - - def test_store_new_root_wiki_success(self) -> None: - # Update the wiki_page with file handle ids - new_wiki_page = self.get_fresh_wiki_page() - new_wiki_page.parent_id = None - - # AND mock the post_wiki_page response - post_api_response = copy.deepcopy(self.api_response) - post_api_response["parentId"] = None - post_api_response["markdownFileHandleId"] = "markdown_file_handle_id" - post_api_response["attachmentFileHandleIds"] = [ - "attachment_file_handle_id_1", - "attachment_file_handle_id_2", - ] - - # Create mock WikiPage objects with the expected file handle IDs for markdown - mock_wiki_with_markdown = copy.deepcopy(new_wiki_page) - mock_wiki_with_markdown.markdown_file_handle_id = "markdown_file_handle_id" - - # Create mock WikiPage objects with the expected file handle IDs for attachments - mock_wiki_with_attachments = copy.deepcopy(mock_wiki_with_markdown) - mock_wiki_with_attachments.attachment_file_handle_ids = [ - "attachment_file_handle_id_1", - "attachment_file_handle_id_2", - ] - - # AND mock responses - with patch( - "synapseclient.models.wiki.WikiPage._determine_wiki_action", - return_value="create_root_wiki_page", - ), patch( - "synapseclient.models.wiki.WikiPage._get_markdown_file_handle", - return_value=mock_wiki_with_markdown, - ), patch( - "synapseclient.models.wiki.WikiPage._get_attachment_file_handles", - return_value=mock_wiki_with_attachments, - ), patch( - "synapseclient.models.wiki.post_wiki_page", return_value=post_api_response - ) as mock_post_wiki, patch.object( - self.syn.logger, "info" - ) as mock_logger: - # WHEN I call `store` - results = new_wiki_page.store(synapse_client=self.syn) - - # THEN log messages should be printed - assert mock_logger.call_count == 2 - mock_logger.assert_has_calls( - [ - call( - "No wiki page exists within the owner. Create a new wiki page." - ), - call( - f"Created wiki page: {post_api_response['title']} with ID: {post_api_response['id']}." - ), - ] - ) - # Update the wiki_page with file handle ids for validation - new_wiki_page.markdown_file_handle_id = "markdown_file_handle_id" - new_wiki_page.attachment_file_handle_ids = [ - "attachment_file_handle_id_1", - "attachment_file_handle_id_2", - ] - - # AND the wiki should be created - mock_post_wiki.assert_called_once_with( - owner_id="syn123", - request=new_wiki_page.to_synapse_request(), - synapse_client=self.syn, - ) - # AND the result should be filled with the response - expected_results = new_wiki_page.fill_from_dict(post_api_response) - assert results == expected_results - - def test_store_update_existing_wiki_success(self) -> None: - # Update the wiki_page - new_wiki_page = self.get_fresh_wiki_page() - new_wiki_page.title = "Updated Wiki Page" - new_wiki_page.parent_id = None - new_wiki_page.etag = None - - # AND mock the get_wiki_page response - mock_get_wiki_response = copy.deepcopy(self.api_response) - mock_get_wiki_response["parentWikiId"] = None - mock_get_wiki_response["markdown"] = None - mock_get_wiki_response["attachments"] = [] - mock_get_wiki_response["markdownFileHandleId"] = None - mock_get_wiki_response["attachmentFileHandleIds"] = [] - - # Create mock WikiPage objects - mock_wiki_with_markdown = self.get_fresh_wiki_page() - mock_wiki_with_markdown.title = "Updated Wiki Page" - mock_wiki_with_markdown.parent_id = None - mock_wiki_with_markdown.markdown_file_handle_id = "markdown_file_handle_id" - - # Create mock WikiPage objects with the expected file handle IDs for attachments - mock_wiki_with_attachments = copy.deepcopy(mock_wiki_with_markdown) - mock_wiki_with_attachments.attachment_file_handle_ids = [ - "attachment_file_handle_id_1", - "attachment_file_handle_id_2", - ] - - # AND mock the put_wiki_page response - # Create mock WikiPage objects with the expected file handle IDs for markdown - mock_put_wiki_response = copy.deepcopy(self.api_response) - mock_put_wiki_response["title"] = "Updated Wiki Page" - mock_put_wiki_response["parentId"] = None - mock_put_wiki_response["markdownFileHandleId"] = "markdown_file_handle_id" - mock_put_wiki_response["attachmentFileHandleIds"] = [ - "attachment_file_handle_id_1", - "attachment_file_handle_id_2", - ] - - # AND mock responses - with patch( - "synapseclient.models.wiki.WikiPage._determine_wiki_action", - return_value="update_existing_wiki_page", - ), patch( - "synapseclient.models.wiki.WikiPage._get_markdown_file_handle", - return_value=mock_wiki_with_markdown, - ), patch( - "synapseclient.models.wiki.WikiPage._get_attachment_file_handles", - return_value=mock_wiki_with_attachments, - ), patch( - "synapseclient.models.wiki.get_wiki_page", - return_value=mock_get_wiki_response, - ) as mock_get_wiki, patch( - "synapseclient.models.wiki.put_wiki_page", - return_value=mock_put_wiki_response, - ) as mock_put_wiki, patch.object( - self.syn.logger, "info" - ) as mock_logger: - # WHEN I call `store` - results = new_wiki_page.store(synapse_client=self.syn) - # THEN the existing wiki should be retrieved - mock_get_wiki.assert_called_once_with( - owner_id="syn123", - wiki_id="wiki1", - wiki_version="0", - synapse_client=self.syn, - ) - - # AND the wiki should be updated after merging dataclass objects - new_wiki_page.etag = "etag123" - new_wiki_page.created_on = "2023-01-01T00:00:00.000Z" - new_wiki_page.created_by = "12345" - new_wiki_page.modified_on = "2023-01-02T00:00:00.000Z" - new_wiki_page.modified_by = "12345" - new_wiki_page.markdown_file_handle_id = "markdown_file_handle_id" - new_wiki_page.attachment_file_handle_ids = [ - "attachment_file_handle_id_1", - "attachment_file_handle_id_2", - ] - mock_put_wiki.assert_called_once_with( - owner_id="syn123", - wiki_id="wiki1", - request=new_wiki_page.to_synapse_request(), - synapse_client=self.syn, - ) - - # AND log messages should be printed - assert mock_logger.call_count == 2 - mock_logger.assert_has_calls( - [ - call( - "A wiki page already exists within the owner. Update the existing wiki page." - ), - call( - f"Updated wiki page: {mock_put_wiki_response['title']} with ID: {self.api_response['id']}." - ), - ] - ) - # AND the result should be filled with the response - expected_results = new_wiki_page.fill_from_dict(mock_put_wiki_response) - assert results == expected_results - - def test_store_create_sub_wiki_success(self) -> None: - # AND mock the post_wiki_page response - post_api_response = copy.deepcopy(self.api_response) - post_api_response["markdownFileHandleId"] = "markdown_file_handle_id" - post_api_response["attachmentFileHandleIds"] = [ - "attachment_file_handle_id_1", - "attachment_file_handle_id_2", - ] - - # Create mock WikiPage objects with the expected file handle IDs for markdown - mock_wiki_with_markdown = self.get_fresh_wiki_page() - mock_wiki_with_markdown.markdown_file_handle_id = "markdown_file_handle_id" - - # Create mock WikiPage objects with the expected file handle IDs for attachments - mock_wiki_with_attachments = copy.deepcopy(mock_wiki_with_markdown) - mock_wiki_with_attachments.attachment_file_handle_ids = [ - "attachment_file_handle_id_1", - "attachment_file_handle_id_2", - ] - - # AND mock responses - with patch( - "synapseclient.models.wiki.WikiPage._determine_wiki_action", - return_value="create_sub_wiki_page", - ), patch( - "synapseclient.models.wiki.WikiPage._get_markdown_file_handle", - return_value=mock_wiki_with_markdown, - ), patch( - "synapseclient.models.wiki.WikiPage._get_attachment_file_handles", - return_value=mock_wiki_with_attachments, - ), patch( - "synapseclient.models.wiki.post_wiki_page", return_value=post_api_response - ) as mock_post_wiki, patch.object( - self.syn.logger, "info" - ) as mock_logger: - # WHEN I call `store` - results = self.wiki_page.store(synapse_client=self.syn) - - # THEN log messages should be printed - assert mock_logger.call_count == 2 - mock_logger.assert_has_calls( - [ - call("Creating sub-wiki page under parent ID: parent_wiki"), - call( - f"Created sub-wiki page: {post_api_response['title']} with ID: {post_api_response['id']} under parent: parent_wiki" - ), - ] - ) - - # Update the wiki_page with file handle ids for validation - new_wiki_page = self.get_fresh_wiki_page() - new_wiki_page.markdown_file_handle_id = "markdown_file_handle_id" - new_wiki_page.attachment_file_handle_ids = [ - "attachment_file_handle_id_1", - "attachment_file_handle_id_2", - ] - - # AND the wiki should be created - mock_post_wiki.assert_called_once_with( - owner_id="syn123", - request=new_wiki_page.to_synapse_request(), - synapse_client=self.syn, - ) - - # AND the result should be filled with the response - expected_results = new_wiki_page.fill_from_dict(post_api_response) - assert results == expected_results - - @pytest.mark.parametrize( - "wiki_page, expected_error", - [ - ( - WikiPage(owner_id=None, title="Test Wiki", wiki_version="0"), - "Must provide owner_id to restore a wiki page.", - ), - ( - WikiPage(owner_id="syn123", id=None, wiki_version="0"), - "Must provide id to restore a wiki page.", - ), - ( - WikiPage(owner_id="syn123", id="wiki1", wiki_version=None), - "Must provide wiki_version to restore a wiki page.", - ), - ], - ) - def test_restore_missing_required_parameters( - self, wiki_page, expected_error - ) -> None: - # WHEN I call `restore` - # THEN it should raise ValueError - with patch( - "synapseclient.models.wiki.put_wiki_version" - ) as mocked_put, pytest.raises(ValueError, match=expected_error): - wiki_page.restore(synapse_client=self.syn) - # THEN the API should not be called - mocked_put.assert_not_called() - - def test_restore_success(self) -> None: - new_wiki_page = self.get_fresh_wiki_page() - with patch( - "synapseclient.models.wiki.put_wiki_version", return_value=self.api_response - ) as mock_put_wiki_version: - # WHEN I call `restore` - results = self.wiki_page.restore(synapse_client=self.syn) - - # THEN the API should be called with correct parameters - mock_put_wiki_version.assert_called_once_with( - owner_id="syn123", - wiki_id="wiki1", - wiki_version="0", - request=new_wiki_page.to_synapse_request(), - synapse_client=self.syn, - ) - # AND the result should be filled with the response - expected_results = new_wiki_page.fill_from_dict(self.api_response) - assert results == expected_results - - def test_get_by_id_success(self) -> None: - # GIVEN a WikiPage object with id - wiki = WikiPage( - id="wiki1", - owner_id="syn123", - ) - - # AND a mock response - with patch("synapseclient.models.wiki.get_wiki_page") as mock_get_wiki: - mock_get_wiki.return_value = self.api_response - - # WHEN I call `get` - results = wiki.get(synapse_client=self.syn) - - # THEN the API should be called with correct parameters - mock_get_wiki.assert_called_once_with( - owner_id="syn123", - wiki_id="wiki1", - wiki_version=None, - synapse_client=self.syn, - ) - - # AND the result should be filled with the response - wiki_page = self.get_fresh_wiki_page() - expected_wiki = wiki_page.fill_from_dict(self.api_response) - expected_wiki.attachments = wiki.attachments - expected_wiki.markdown = wiki.markdown - expected_wiki.wiki_version = wiki.wiki_version - assert results == expected_wiki - - def test_get_by_title_success(self) -> None: - # GIVEN a WikiPage object with title but no id - wiki = WikiPage( - title="Test Wiki", - owner_id="syn123", - ) - - # AND mock responses - mock_responses = [ - {"id": "wiki1", "title": "Test Wiki", "parentId": None}, - {"id": "wiki2", "title": "Test Wiki 2", "parentId": None}, - ] - - # Create an async generator function - async def mock_async_generator(*args, **kwargs): - for item in mock_responses: - yield item - - with patch( - "synapseclient.models.wiki.get_wiki_header_tree", - side_effect=mock_async_generator, - ) as mock_get_header_tree, patch( - "synapseclient.models.wiki.get_wiki_page", return_value=self.api_response - ) as mock_get_wiki: - # WHEN I call `get` - results = wiki.get(synapse_client=self.syn) - - # THEN the header tree should be retrieved - mock_get_header_tree.assert_called_once_with( - owner_id="syn123", - synapse_client=self.syn, - ) - - # AND the wiki should be retrieved by id - mock_get_wiki.assert_called_once_with( - owner_id="syn123", - wiki_id="wiki1", - wiki_version=None, - synapse_client=self.syn, - ) - - # AND the result should be filled with the response - wiki_page = self.get_fresh_wiki_page() - expected_wiki = wiki_page.fill_from_dict(self.api_response) - expected_wiki.attachments = wiki.attachments - expected_wiki.markdown = wiki.markdown - expected_wiki.wiki_version = wiki.wiki_version - assert results == expected_wiki - - def test_get_by_title_not_found(self) -> None: - # GIVEN a WikiPage object with title but no id - wiki = WikiPage( - title="Non-existent Wiki", - owner_id="syn123", - ) - - # AND mock responses that don't contain the title - mock_responses = [{"id": "wiki1", "title": "Different Wiki", "parentId": None}] - - # Create an async generator function - async def mock_async_generator( - *args, **kwargs - ) -> AsyncGenerator[Dict[str, Any], None]: - for item in mock_responses: - yield item - - with patch( - "synapseclient.models.wiki.get_wiki_header_tree", - side_effect=mock_async_generator, - ) as mock_get_header_tree: - # WHEN I call `get` - # THEN it should raise ValueError - with pytest.raises( - ValueError, match="No wiki page found with title: Non-existent Wiki" - ): - wiki.get(synapse_client=self.syn) - mock_get_header_tree.assert_called_once_with( - owner_id="syn123", - synapse_client=self.syn, - ) - - @pytest.mark.parametrize( - "wiki_page, expected_error", - [ - ( - WikiPage(id="wiki1"), - "Must provide owner_id to delete a wiki page.", - ), - ( - WikiPage(owner_id="syn123"), - "Must provide id to delete a wiki page.", - ), - ], - ) - def test_delete_missing_required_parameters( - self, wiki_page, expected_error - ) -> None: - # WHEN I call `delete` - # THEN it should raise ValueError - with patch( - "synapseclient.models.wiki.delete_wiki_page" - ) as mocked_delete, pytest.raises(ValueError, match=expected_error): - wiki_page.delete(synapse_client=self.syn) - # THEN the API should not be called - mocked_delete.assert_not_called() - - def test_delete_success(self) -> None: - # WHEN I call `delete` - with patch("synapseclient.models.wiki.delete_wiki_page") as mock_delete_wiki: - self.wiki_page.delete(synapse_client=self.syn) - - # THEN the API should be called with correct parameters - mock_delete_wiki.assert_called_once_with( - owner_id="syn123", - wiki_id="wiki1", - synapse_client=self.syn, - ) - - @pytest.mark.parametrize( - "wiki_page, expected_error", - [ - ( - WikiPage(id="wiki1"), - "Must provide owner_id to get attachment handles.", - ), - ( - WikiPage(owner_id="syn123"), - "Must provide id to get attachment handles.", - ), - ], - ) - def test_get_attachment_handles_missing_required_parameters( - self, wiki_page, expected_error - ) -> None: - # WHEN I call `get_attachment_handles` - # THEN it should raise ValueError - with patch( - "synapseclient.models.wiki.get_attachment_handles" - ) as mocked_get, pytest.raises(ValueError, match=expected_error): - wiki_page.get_attachment_handles(synapse_client=self.syn) - # THEN the API should not be called - mocked_get.assert_not_called() - - def test_get_attachment_handles_success(self) -> None: - # mock responses - mock_handles = [{"id": "handle1", "fileName": "test.txt"}] - with patch( - "synapseclient.models.wiki.get_attachment_handles", - return_value=mock_handles, - ) as mock_get_handles: - # WHEN I call `get_attachment_handles` - results = self.wiki_page.get_attachment_handles(synapse_client=self.syn) - - # THEN the API should be called with correct parameters - mock_get_handles.assert_called_once_with( - owner_id="syn123", - wiki_id="wiki1", - wiki_version="0", - synapse_client=self.syn, - ) - # AND the result should be the handles - assert results == mock_handles - - @pytest.mark.parametrize( - "wiki_page, file_name, expected_error", - [ - ( - WikiPage(id="wiki1"), - "test.txt", - "Must provide owner_id to get attachment URL.", - ), - ( - WikiPage(owner_id="syn123"), - "test.txt", - "Must provide id to get attachment URL.", - ), - ( - WikiPage(owner_id="syn123", id="wiki1"), - None, - "Must provide file_name to get attachment URL.", - ), - ], - ) - def test_get_attachment_missing_required_parameters( - self, file_name, wiki_page, expected_error - ) -> None: - # WHEN I call `get_attachment` - # THEN it should raise ValueError - with patch( - "synapseclient.models.wiki.get_attachment_url" - ) as mocked_get, pytest.raises(ValueError, match=expected_error): - wiki_page.get_attachment( - file_name=file_name, - synapse_client=self.syn, - ) - # THEN the API should not be called - mocked_get.assert_not_called() - - @pytest.mark.parametrize("file_size", [8 * 1024 * 1024 - 1, 8 * 1024 * 1024 + 1]) - def test_get_attachment_download_file_success(self, file_size) -> None: - # AND mock responses - mock_attachment_url = "https://example.com/attachment.txt" - mock_filehandle_dict = { - "list": [ - { - "fileName": "test.txt.gz", - "contentSize": str(file_size), - } - ] - } - - with patch( - "synapseclient.models.wiki.get_attachment_url", - return_value=mock_attachment_url, - ) as mock_get_url, patch( - "synapseclient.models.wiki.get_attachment_handles", - return_value=mock_filehandle_dict, - ) as mock_get_handles, patch( - "synapseclient.models.wiki.download_from_url", - return_value="/tmp/download/test.txt.gz", - ) as mock_download_from_url, patch( - "synapseclient.models.wiki.download_from_url_multi_threaded", - return_value="/tmp/download/test.txt.gz", - ) as mock_download_from_url_multi_threaded, patch( - "synapseclient.models.wiki._pre_signed_url_expiration_time", - return_value="2030-01-01T00:00:00.000Z", - ) as mock_expiration_time, patch.object( - self.syn.logger, "info" - ) as mock_logger_info, patch( - "os.remove" - ) as mock_remove, patch( - "synapseclient.models.wiki.WikiPage.unzip_gzipped_file" - ) as mock_unzip_gzipped_file, patch.object( - self.syn.logger, "debug" - ) as mock_logger_debug: - # WHEN I call `get_attachment` with download_file=True - result = self.wiki_page.get_attachment( - file_name="test.txt", - download_file=True, - download_location="/tmp/download", - synapse_client=self.syn, - ) - - # THEN the attachment URL should be retrieved - mock_get_url.assert_called_once_with( - owner_id="syn123", - wiki_id="wiki1", - file_name="test.txt.gz", - wiki_version="0", - synapse_client=self.syn, - ) - - # AND the attachment handles should be retrieved - mock_get_handles.assert_called_once_with( - owner_id="syn123", - wiki_id="wiki1", - wiki_version="0", - synapse_client=self.syn, - ) - - # AND the expiration time should be calculated - mock_expiration_time.assert_called_once_with(mock_attachment_url) - - # AND the appropriate download method should be called based on file size - if file_size < 8 * 1024 * 1024: - # Single-threaded download for files smaller than 8 MiB - mock_download_from_url.assert_called_once_with( - url=mock_attachment_url, - destination="/tmp/download", - url_is_presigned=True, - synapse_client=self.syn, - ) - mock_download_from_url_multi_threaded.assert_not_called() - - else: - # construct a mock presigned url info - mock_presigned_url_info = PresignedUrlInfo( - file_name="test.txt.gz", - url=mock_attachment_url, - expiration_utc="2030-01-01T00:00:00.000Z", - ) - # Multi-threaded download for files larger than or equal to 8 MiB - mock_download_from_url_multi_threaded.assert_called_once_with( - presigned_url=mock_presigned_url_info, - destination="/tmp/download", - synapse_client=self.syn, - ) - mock_download_from_url.assert_not_called() - - # AND debug log should be called once (only the general one) - mock_logger_info.assert_called_once_with( - f"Downloaded file test.txt to {result}." - ) - # AND the file should be unzipped - mock_unzip_gzipped_file.assert_called_once_with("/tmp/download/test.txt.gz") - # AND the gzipped file should be removed - mock_remove.assert_called_once_with("/tmp/download/test.txt.gz") - # AND debug log should be called - mock_logger_debug.assert_called_once_with( - "Removed the gzipped file /tmp/download/test.txt.gz." - ) - - def test_get_attachment_no_file_download(self) -> None: - with patch( - "synapseclient.models.wiki.get_attachment_url", - return_value="https://example.com/attachment.txt", - ) as mock_get_url: - # WHEN I call `get_attachment` with download_file=False - # THEN it should return the attachment URL - results = self.wiki_page.get_attachment( - file_name="test.txt.gz", - download_file=False, - synapse_client=self.syn, - ) - # AND the result should be the attachment URL - assert results == "https://example.com/attachment.txt" - - def test_get_attachment_download_file_missing_location(self) -> None: - # GIVEN a WikiPage object - wiki = WikiPage( - id="wiki1", - owner_id="syn123", - wiki_version="0", - ) - - # AND a mock attachment URL - mock_attachment_url = "https://example.com/attachment.txt" - - with patch( - "synapseclient.models.wiki.get_attachment_url", - return_value=mock_attachment_url, - ) as mock_get_url, patch( - "synapseclient.models.wiki.get_attachment_handles", - ) as mock_get_handles: - # WHEN I call `get_attachment` with download_file=True but no download_location - # THEN it should raise ValueError - with pytest.raises( - ValueError, match="Must provide download_location to download a file." - ): - wiki.get_attachment( - file_name="test.txt", - download_file=True, - download_location=None, - synapse_client=self.syn, - ) - - # AND the attachment URL should still be retrieved - mock_get_url.assert_called_once_with( - owner_id="syn123", - wiki_id="wiki1", - file_name="test.txt.gz", - wiki_version="0", - synapse_client=self.syn, - ) - # AND the attachment handles should not be retrieved - mock_get_handles.assert_not_called() - - @pytest.mark.parametrize( - "wiki_page, file_name, expected_error", - [ - ( - WikiPage(id="wiki1"), - "test.txt", - "Must provide owner_id to get attachment preview URL.", - ), - ( - WikiPage(owner_id="syn123"), - "test.txt", - "Must provide id to get attachment preview URL.", - ), - ( - WikiPage(owner_id="syn123", id="wiki1"), - None, - "Must provide file_name to get attachment preview URL.", - ), - ], - ) - def test_get_attachment_preview_missing_required_parameters( - self, file_name, wiki_page, expected_error - ) -> None: - # WHEN I call `get_attachment_preview` - # THEN it should raise ValueError - with patch( - "synapseclient.models.wiki.get_attachment_preview_url" - ) as mocked_get, pytest.raises(ValueError, match=expected_error): - wiki_page.get_attachment_preview( - file_name=file_name, - synapse_client=self.syn, - ) - # THEN the API should not be called - mocked_get.assert_not_called() - - @pytest.mark.parametrize("file_size", [8 * 1024 * 1024 - 1, 8 * 1024 * 1024 + 1]) - def test_get_attachment_preview_download_file_success(self, file_size) -> None: - # Mock responses - mock_attachment_url = "https://example.com/attachment.txt" - mock_filehandle_dict = { - "list": [ - { - "fileName": "test.txt.gz", - "contentSize": str(file_size), - } - ] - } - - with patch( - "synapseclient.models.wiki.get_attachment_preview_url", - return_value=mock_attachment_url, - ) as mock_get_url, patch( - "synapseclient.models.wiki.get_attachment_handles", - return_value=mock_filehandle_dict, - ) as mock_get_handles, patch( - "synapseclient.models.wiki.download_from_url", - return_value="/tmp/download/test.txt.gz", - ) as mock_download_from_url, patch( - "synapseclient.models.wiki.download_from_url_multi_threaded", - return_value="/tmp/download/test.txt.gz", - ) as mock_download_from_url_multi_threaded, patch( - "synapseclient.models.wiki._pre_signed_url_expiration_time", - return_value="2030-01-01T00:00:00.000Z", - ) as mock_expiration_time, patch.object( - self.syn.logger, "info" - ) as mock_logger_info: - # WHEN I call `get_attachment_preview` with download_file=True - result = self.wiki_page.get_attachment_preview( - file_name="test.txt", - download_file=True, - download_location="/tmp/download", - synapse_client=self.syn, - ) - - # THEN the attachment URL should be retrieved - mock_get_url.assert_called_once_with( - owner_id="syn123", - wiki_id="wiki1", - file_name="test.txt.gz", - wiki_version="0", - synapse_client=self.syn, - ) - - # AND the attachment handles should be retrieved - mock_get_handles.assert_called_once_with( - owner_id="syn123", - wiki_id="wiki1", - wiki_version="0", - synapse_client=self.syn, - ) - - # AND the expiration time should be calculated - mock_expiration_time.assert_called_once_with(mock_attachment_url) - - # AND the appropriate download method should be called based on file size - if file_size < 8 * 1024 * 1024: - # Single-threaded download for files smaller than 8 MiB - mock_download_from_url.assert_called_once_with( - url=mock_attachment_url, - destination="/tmp/download", - url_is_presigned=True, - synapse_client=self.syn, - ) - mock_download_from_url_multi_threaded.assert_not_called() - - else: - # construct a mock presigned url info - mock_presigned_url_info = PresignedUrlInfo( - file_name="test.txt.gz", - url=mock_attachment_url, - expiration_utc="2030-01-01T00:00:00.000Z", - ) - # Multi-threaded download for files larger than or equal to 8 MiB - mock_download_from_url_multi_threaded.assert_called_once_with( - presigned_url=mock_presigned_url_info, - destination="/tmp/download", - synapse_client=self.syn, - ) - mock_download_from_url.assert_not_called() - - # AND debug log should be called once (only the general one) - mock_logger_info.assert_called_once_with( - f"Downloaded the preview file test.txt to {result}." - ) - - def test_get_attachment_preview_no_file_download(self) -> None: - with patch( - "synapseclient.models.wiki.get_attachment_preview_url", - return_value="https://example.com/attachment.txt", - ) as mock_get_url: - # WHEN I call `get_attachment_preview` with download_file=False - # THEN it should return the attachment URL - results = self.wiki_page.get_attachment_preview( - file_name="test.txt", - download_file=False, - synapse_client=self.syn, - ) - # AND the result should be the attachment URL - assert results == "https://example.com/attachment.txt" - - def test_get_attachment_preview_download_file_missing_location(self) -> None: - # GIVEN a WikiPage object - wiki = WikiPage( - id="wiki1", - owner_id="syn123", - wiki_version="0", - ) - - # AND a mock attachment URL - mock_attachment_url = "https://example.com/attachment.txt" - - with patch( - "synapseclient.models.wiki.get_attachment_preview_url", - return_value=mock_attachment_url, - ) as mock_get_url, patch( - "synapseclient.models.wiki.get_attachment_handles" - ) as mock_get_handles: - # WHEN I call `get_attachment_preview` with download_file=True but no download_location - # THEN it should raise ValueError - with pytest.raises( - ValueError, match="Must provide download_location to download a file." - ): - wiki.get_attachment_preview( - file_name="test.txt", - download_file=True, - download_location=None, - synapse_client=self.syn, - ) - - # AND the attachment URL should still be retrieved - mock_get_url.assert_called_once_with( - owner_id="syn123", - wiki_id="wiki1", - file_name="test.txt.gz", - wiki_version="0", - synapse_client=self.syn, - ) - # AND the attachment handles should not be retrieved - mock_get_handles.assert_not_called() - - @pytest.mark.parametrize( - "wiki_page, expected_error", - [ - ( - WikiPage(id="wiki1"), - "Must provide owner_id to get markdown URL.", - ), - ( - WikiPage(owner_id="syn123"), - "Must provide id to get markdown URL.", - ), - ], - ) - def test_get_markdown_file_missing_required_parameters( - self, wiki_page, expected_error - ) -> None: - # WHEN I call `get_markdown` - # THEN it should raise ValueError - with patch( - "synapseclient.models.wiki.get_markdown_url" - ) as mocked_get, pytest.raises(ValueError, match=expected_error): - wiki_page.get_markdown_file(synapse_client=self.syn) - # THEN the API should not be called - mocked_get.assert_not_called() - - def test_get_markdown_file_download_file_success(self) -> None: - # Mock responses - mock_markdown_url = "https://example.com/markdown.md.gz" - - with patch( - "synapseclient.models.wiki.get_markdown_url", - return_value=mock_markdown_url, - ) as mock_get_url, patch( - "synapseclient.models.wiki.download_from_url", - return_value="/tmp/download/markdown.md.gz", - ) as mock_download_from_url, patch( - "synapseclient.models.wiki.WikiPage.unzip_gzipped_file" - ) as mock_unzip_gzipped_file, patch.object( - self.syn.logger, "info" - ) as mock_logger_info, patch.object( - self.syn.logger, "debug" - ) as mock_logger_debug, patch( - "os.remove" - ) as mock_remove: - # WHEN I call `get_markdown_async` with download_file=True - result = self.wiki_page.get_markdown_file( - download_file=True, - download_location="/tmp/download", - synapse_client=self.syn, - ) - - # THEN the markdown URL should be retrieved - mock_get_url.assert_called_once_with( - owner_id="syn123", - wiki_id="wiki1", - wiki_version="0", - synapse_client=self.syn, - ) - - # AND the file should be downloaded using single-threaded download - mock_download_from_url.assert_called_once_with( - url=mock_markdown_url, - destination="/tmp/download", - url_is_presigned=True, - synapse_client=self.syn, - ) - # AND the file should be unzipped - mock_unzip_gzipped_file.assert_called_once_with( - "/tmp/download/markdown.md.gz" - ) - # AND debug log should be called - mock_logger_info.assert_called_once_with( - f"Downloaded and unzipped the markdown file for wiki page wiki1 to {result}." - ) - # AND the gzipped file should be removed - mock_remove.assert_called_once_with("/tmp/download/markdown.md.gz") - # AND debug log should be called - mock_logger_debug.assert_called_once_with( - f"Removed the gzipped file /tmp/download/markdown.md.gz." - ) - - def test_get_markdown_file_no_file_download(self) -> None: - with patch( - "synapseclient.models.wiki.get_markdown_url", - return_value="https://example.com/markdown.md", - ) as mock_get_url: - # WHEN I call `get_markdown_async` with download_file=False - results = self.wiki_page.get_markdown_file( - download_file=False, - synapse_client=self.syn, - ) - - # THEN the markdown URL should be retrieved - mock_get_url.assert_called_once_with( - owner_id="syn123", - wiki_id="wiki1", - wiki_version="0", - synapse_client=self.syn, - ) - - # AND the result should be the markdown URL - assert results == "https://example.com/markdown.md" - - def test_get_markdown_file_download_file_missing_location(self) -> None: - # GIVEN a WikiPage object - wiki = WikiPage( - id="wiki1", - owner_id="syn123", - wiki_version="0", - ) - - # AND a mock markdown URL - mock_markdown_url = "https://example.com/markdown.md" - - with patch( - "synapseclient.models.wiki.get_markdown_url", - return_value=mock_markdown_url, - ) as mock_get_url, patch( - "synapseclient.models.wiki.get_attachment_handles" - ) as mock_get_handles: - # WHEN I call `get_markdown_async` with download_file=True but no download_location - # THEN it should raise ValueError - with pytest.raises( - ValueError, match="Must provide download_location to download a file." - ): - wiki.get_markdown_file( - download_file=True, - download_location=None, - synapse_client=self.syn, - ) - - # AND the markdown URL should still be retrieved - mock_get_url.assert_called_once_with( - owner_id="syn123", - wiki_id="wiki1", - wiki_version="0", - synapse_client=self.syn, - ) - # AND the attachment handles should not be retrieved - mock_get_handles.assert_not_called() - - def test_get_markdown_file_with_different_wiki_version(self) -> None: - # GIVEN a WikiPage object with a specific wiki version - wiki = WikiPage( - id="wiki1", - owner_id="syn123", - wiki_version="2", - ) - - with patch( - "synapseclient.models.wiki.get_markdown_url", - return_value="https://example.com/markdown_v2.md", - ) as mock_get_url: - # WHEN I call `get_markdown_async` - results = wiki.get_markdown_file( - download_file=False, - synapse_client=self.syn, - ) - - # THEN the markdown URL should be retrieved with the correct wiki version - mock_get_url.assert_called_once_with( - owner_id="syn123", - wiki_id="wiki1", - wiki_version="2", - synapse_client=self.syn, - ) - - # AND the result should be the markdown URL - assert results == "https://example.com/markdown_v2.md" - - def test_get_markdown_file_with_none_wiki_version(self) -> None: - # GIVEN a WikiPage object with None wiki version - wiki = WikiPage( - id="wiki1", - owner_id="syn123", - wiki_version=None, - ) - - with patch( - "synapseclient.models.wiki.get_markdown_url", - return_value="https://example.com/markdown_latest.md", - ) as mock_get_url: - # WHEN I call `get_markdown_async` - results = wiki.get_markdown_file( - download_file=False, - synapse_client=self.syn, - ) - - # THEN the markdown URL should be retrieved with None wiki version - mock_get_url.assert_called_once_with( - owner_id="syn123", - wiki_id="wiki1", - wiki_version=None, - synapse_client=self.syn, - ) - - # AND the result should be the markdown URL - assert results == "https://example.com/markdown_latest.md" diff --git a/tests/unit/synapseclient/operations/unit_test_delete_operations.py b/tests/unit/synapseclient/operations/unit_test_delete_operations.py new file mode 100644 index 000000000..fc6595536 --- /dev/null +++ b/tests/unit/synapseclient/operations/unit_test_delete_operations.py @@ -0,0 +1,484 @@ +"""Unit tests for delete_operations routing logic.""" + +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from synapseclient.operations.delete_operations import delete_async + + +class TestDeleteStringIdRoute: + """Tests for string ID deletion routing in delete_async.""" + + @patch( + "synapseclient.operations.delete_operations.delete_entity", + new_callable=AsyncMock, + ) + @patch("synapseclient.operations.delete_operations.is_synapse_id_str") + async def test_delete_string_id(self, mock_is_synapse_id_str, mock_delete_entity): + """Test that a plain string Synapse ID is deleted via delete_entity.""" + # GIVEN a valid string synapse ID + mock_is_synapse_id_str.return_value = "syn123456" + + # WHEN I call delete_async with a string ID + result = await delete_async(entity="syn123456", synapse_client=MagicMock()) + + # THEN delete_entity is called with the ID and no version + mock_delete_entity.assert_awaited_once() + call_kwargs = mock_delete_entity.call_args[1] + assert call_kwargs["entity_id"] == "syn123456" + assert call_kwargs["version_number"] is None + assert result is None + + @patch( + "synapseclient.operations.delete_operations.delete_entity", + new_callable=AsyncMock, + ) + @patch("synapseclient.operations.delete_operations.is_synapse_id_str") + async def test_delete_string_id_with_embedded_version( + self, mock_is_synapse_id_str, mock_delete_entity + ): + """Test that a string ID with embedded version (syn123.4) requires version_only=True.""" + # GIVEN a string synapse ID with embedded version + mock_is_synapse_id_str.return_value = "syn123.4" + + # WHEN/THEN delete_async raises ValueError without version_only=True + with pytest.raises(ValueError, match="version_only=True"): + await delete_async(entity="syn123.4", synapse_client=MagicMock()) + + @patch( + "synapseclient.operations.delete_operations.delete_entity", + new_callable=AsyncMock, + ) + @patch("synapseclient.operations.delete_operations.is_synapse_id_str") + async def test_delete_string_id_with_embedded_version_and_version_only( + self, mock_is_synapse_id_str, mock_delete_entity + ): + """Test that a string ID with embedded version deletes when version_only=True.""" + # GIVEN a string synapse ID with embedded version + mock_is_synapse_id_str.return_value = "syn123.4" + + # WHEN I call delete_async with version_only=True + result = await delete_async( + entity="syn123.4", version_only=True, synapse_client=MagicMock() + ) + + # THEN delete_entity is called with the parsed ID and version + mock_delete_entity.assert_awaited_once() + call_kwargs = mock_delete_entity.call_args[1] + assert call_kwargs["entity_id"] == "syn123" + assert call_kwargs["version_number"] == 4 + assert result is None + + @patch( + "synapseclient.operations.delete_operations.delete_entity", + new_callable=AsyncMock, + ) + @patch("synapseclient.operations.delete_operations.is_synapse_id_str") + async def test_delete_string_id_with_explicit_version_overrides( + self, mock_is_synapse_id_str, mock_delete_entity + ): + """Test that explicit version parameter overrides embedded version.""" + # GIVEN a string synapse ID with embedded version + mock_is_synapse_id_str.return_value = "syn123.4" + + # WHEN I call delete_async with version=7 and version_only=True + result = await delete_async( + entity="syn123.4", + version=7, + version_only=True, + synapse_client=MagicMock(), + ) + + # THEN delete_entity is called with version=7 (not 4) + mock_delete_entity.assert_awaited_once() + call_kwargs = mock_delete_entity.call_args[1] + assert call_kwargs["entity_id"] == "syn123" + assert call_kwargs["version_number"] == 7 + assert result is None + + async def test_delete_invalid_string_raises_value_error(self): + """Test that an invalid string raises ValueError.""" + # GIVEN an invalid string + # WHEN/THEN delete_async raises ValueError + with pytest.raises(ValueError, match="Invalid Synapse ID"): + await delete_async(entity="not_a_synapse_id", synapse_client=MagicMock()) + + +class TestDeleteFileEntityRoute: + """Tests for File entity deletion routing in delete_async.""" + + async def test_delete_file_entity(self): + """Test that a File entity is deleted via delete_async.""" + # GIVEN a mock File entity + from synapseclient.models import File + + mock_file = File(id="syn123456") + mock_file.delete_async = AsyncMock(return_value=None) + + # WHEN I call delete_async + result = await delete_async(entity=mock_file, synapse_client=MagicMock()) + + # THEN delete_async is called with version_only=False + mock_file.delete_async.assert_awaited_once() + call_kwargs = mock_file.delete_async.call_args[1] + assert call_kwargs["version_only"] is False + assert result is None + + async def test_delete_file_entity_version_only_true(self): + """Test that File version-specific deletion works with version_only=True.""" + # GIVEN a mock File entity with version_number set + from synapseclient.models import File + + mock_file = File(id="syn123456", version_number=3) + mock_file.delete_async = AsyncMock(return_value=None) + + # WHEN I call delete_async with version_only=True + result = await delete_async( + entity=mock_file, version_only=True, synapse_client=MagicMock() + ) + + # THEN delete_async is called with version_only=True + mock_file.delete_async.assert_awaited_once() + call_kwargs = mock_file.delete_async.call_args[1] + assert call_kwargs["version_only"] is True + assert result is None + + async def test_delete_file_entity_version_only_with_explicit_version(self): + """Test that explicit version overrides entity version_number.""" + # GIVEN a mock File entity with version_number=3 + from synapseclient.models import File + + mock_file = File(id="syn123456", version_number=3) + mock_file.delete_async = AsyncMock(return_value=None) + + # WHEN I call delete_async with version=5, version_only=True + result = await delete_async( + entity=mock_file, + version=5, + version_only=True, + synapse_client=MagicMock(), + ) + + # THEN the file's version_number is set to 5 and delete_async is called + assert mock_file.version_number == 5 + mock_file.delete_async.assert_awaited_once() + call_kwargs = mock_file.delete_async.call_args[1] + assert call_kwargs["version_only"] is True + assert result is None + + async def test_delete_file_entity_version_only_without_version_raises(self): + """Test that version_only=True without version raises ValueError.""" + # GIVEN a mock File entity without a version number + from synapseclient.models import File + + mock_file = File(id="syn123456") + mock_file.delete_async = AsyncMock(return_value=None) + + # WHEN/THEN delete_async raises ValueError + with pytest.raises( + ValueError, match="version_only=True requires a version number" + ): + await delete_async( + entity=mock_file, version_only=True, synapse_client=MagicMock() + ) + + +class TestDeleteTableLikeEntityRoute: + """Tests for table-like entity deletion routing in delete_async.""" + + async def test_delete_table_entity(self): + """Test that a Table entity is deleted via delete_async.""" + # GIVEN a mock Table entity + from synapseclient.models import Table + + mock_table = Table(id="syn123456") + mock_table.delete_async = AsyncMock(return_value=None) + + # WHEN I call delete_async + result = await delete_async(entity=mock_table, synapse_client=MagicMock()) + + # THEN delete_async is called + mock_table.delete_async.assert_awaited_once() + assert result is None + + @patch( + "synapseclient.operations.delete_operations.delete_entity", + new_callable=AsyncMock, + ) + async def test_delete_table_version_only(self, mock_delete_entity): + """Test that Table version-specific deletion uses delete_entity API.""" + # GIVEN a mock Table entity with a version + from synapseclient.models import Table + + mock_delete_entity.return_value = None + mock_table = Table(id="syn123456", version_number=2) + mock_table.delete_async = AsyncMock(return_value=None) + + # WHEN I call delete_async with version_only=True + result = await delete_async( + entity=mock_table, version_only=True, synapse_client=MagicMock() + ) + + # THEN delete_entity API is called (not the entity's delete_async) + mock_delete_entity.assert_awaited_once() + call_kwargs = mock_delete_entity.call_args[1] + assert call_kwargs["entity_id"] == "syn123456" + assert call_kwargs["version_number"] == 2 + mock_table.delete_async.assert_not_awaited() + assert result is None + + async def test_delete_table_version_only_without_version_raises(self): + """Test that Table version_only=True without version raises ValueError.""" + # GIVEN a mock Table entity without a version number + from synapseclient.models import Table + + mock_table = Table(id="syn123456") + mock_table.delete_async = AsyncMock(return_value=None) + + # WHEN/THEN delete_async raises ValueError + with pytest.raises( + ValueError, match="version_only=True requires a version number" + ): + await delete_async( + entity=mock_table, version_only=True, synapse_client=MagicMock() + ) + + async def test_delete_dataset_entity(self): + """Test that a Dataset entity routes through table-like deletion.""" + # GIVEN a mock Dataset entity + from synapseclient.models import Dataset + + mock_dataset = Dataset(id="syn123456") + mock_dataset.delete_async = AsyncMock(return_value=None) + + # WHEN I call delete_async + result = await delete_async(entity=mock_dataset, synapse_client=MagicMock()) + + # THEN delete_async is called + mock_dataset.delete_async.assert_awaited_once() + assert result is None + + +class TestDeleteNonVersionableEntityRoute: + """Tests for non-versionable entity deletion (Project, Folder, etc.).""" + + async def test_delete_project_entity(self): + """Test that a Project entity is deleted normally.""" + # GIVEN a mock Project entity + from synapseclient.models import Project + + mock_project = Project(id="syn123456") + mock_project.delete_async = AsyncMock(return_value=None) + + # WHEN I call delete_async + result = await delete_async(entity=mock_project, synapse_client=MagicMock()) + + # THEN delete_async is called + mock_project.delete_async.assert_awaited_once() + assert result is None + + async def test_delete_folder_entity(self): + """Test that a Folder entity is deleted normally.""" + # GIVEN a mock Folder entity + from synapseclient.models import Folder + + mock_folder = Folder(id="syn123456") + mock_folder.delete_async = AsyncMock(return_value=None) + + # WHEN I call delete_async + result = await delete_async(entity=mock_folder, synapse_client=MagicMock()) + + # THEN delete_async is called + mock_folder.delete_async.assert_awaited_once() + assert result is None + + @patch("synapseclient.Synapse.get_client") + async def test_delete_project_with_version_only_warns(self, mock_get_client): + """Test that Project with version_only=True emits a warning.""" + # GIVEN a mock Project entity with version_only=True + from synapseclient.models import Project + + mock_client = MagicMock() + mock_get_client.return_value = mock_client + + mock_project = Project(id="syn123456") + mock_project.delete_async = AsyncMock(return_value=None) + + # WHEN I call delete_async with version_only=True + result = await delete_async( + entity=mock_project, version_only=True, synapse_client=MagicMock() + ) + + # THEN a warning is emitted and the entity is still deleted + mock_client.logger.warning.assert_called_once() + warning_msg = mock_client.logger.warning.call_args[0][0] + assert "does not support version-specific deletion" in warning_msg + mock_project.delete_async.assert_awaited_once() + assert result is None + + async def test_delete_evaluation_entity(self): + """Test that an Evaluation entity is deleted normally.""" + # GIVEN a mock Evaluation entity + from synapseclient.models import Evaluation + + mock_eval = Evaluation(id="syn123456") + mock_eval.delete_async = AsyncMock(return_value=None) + + # WHEN I call delete_async + result = await delete_async(entity=mock_eval, synapse_client=MagicMock()) + + # THEN delete_async is called + mock_eval.delete_async.assert_awaited_once() + assert result is None + + async def test_delete_team_entity(self): + """Test that a Team entity is deleted normally.""" + # GIVEN a mock Team entity + from synapseclient.models import Team + + mock_team = Team(id=12345, name="Test Team") + mock_team.delete_async = AsyncMock(return_value=None) + + # WHEN I call delete_async + result = await delete_async(entity=mock_team, synapse_client=MagicMock()) + + # THEN delete_async is called + mock_team.delete_async.assert_awaited_once() + assert result is None + + async def test_delete_schema_organization_entity(self): + """Test that a SchemaOrganization entity is deleted normally.""" + # GIVEN a mock SchemaOrganization entity + from synapseclient.models import SchemaOrganization + + mock_org = SchemaOrganization(name="testorg") + mock_org.delete_async = AsyncMock(return_value=None) + + # WHEN I call delete_async + result = await delete_async(entity=mock_org, synapse_client=MagicMock()) + + # THEN delete_async is called + mock_org.delete_async.assert_awaited_once() + assert result is None + + async def test_delete_curation_task_entity(self): + """Test that a CurationTask entity is deleted normally.""" + # GIVEN a mock CurationTask entity + from synapseclient.models import CurationTask + + mock_task = CurationTask(project_id="syn123") + mock_task.delete_async = AsyncMock(return_value=None) + + # WHEN I call delete_async + result = await delete_async(entity=mock_task, synapse_client=MagicMock()) + + # THEN delete_async is called + mock_task.delete_async.assert_awaited_once() + assert result is None + + +class TestDeleteJSONSchemaRoute: + """Tests for JSONSchema entity deletion routing in delete_async.""" + + async def test_delete_json_schema_without_version(self): + """Test that JSONSchema is deleted without version parameter.""" + # GIVEN a mock JSONSchema entity + from synapseclient.models import JSONSchema + + mock_schema = JSONSchema(organization_name="testorg", name="testschema") + mock_schema.delete_async = AsyncMock(return_value=None) + + # WHEN I call delete_async + result = await delete_async(entity=mock_schema, synapse_client=MagicMock()) + + # THEN delete_async is called without version + mock_schema.delete_async.assert_awaited_once() + call_kwargs = mock_schema.delete_async.call_args[1] + assert "version" not in call_kwargs + assert result is None + + async def test_delete_json_schema_with_version(self): + """Test that JSONSchema is deleted with version parameter.""" + # GIVEN a mock JSONSchema entity + from synapseclient.models import JSONSchema + + mock_schema = JSONSchema(organization_name="testorg", name="testschema") + mock_schema.delete_async = AsyncMock(return_value=None) + + # WHEN I call delete_async with a version + result = await delete_async( + entity=mock_schema, version="1.0.0", synapse_client=MagicMock() + ) + + # THEN delete_async is called with version as string + mock_schema.delete_async.assert_awaited_once() + call_kwargs = mock_schema.delete_async.call_args[1] + assert call_kwargs["version"] == "1.0.0" + assert result is None + + +class TestDeleteUnsupportedEntity: + """Tests for unsupported entity type in delete_async.""" + + async def test_unsupported_entity_raises_value_error(self): + """Test that an unsupported entity type raises ValueError.""" + # GIVEN an object that is not a supported entity type + unsupported_entity = MagicMock() + unsupported_entity.__class__ = type("UnsupportedEntity", (), {}) + + # WHEN/THEN delete_async raises ValueError + with pytest.raises(ValueError, match="Unsupported entity type"): + await delete_async(entity=unsupported_entity, synapse_client=MagicMock()) + + +class TestDeleteVersionConflictWarning: + """Tests for version conflict warning behavior.""" + + @patch("synapseclient.Synapse.get_client") + async def test_version_conflict_emits_warning(self, mock_get_client): + """Test that conflicting version parameter and entity version emits warning.""" + # GIVEN a mock File entity with version_number=3 but passing version=5 + from synapseclient.models import File + + mock_client = MagicMock() + mock_get_client.return_value = mock_client + + mock_file = File(id="syn123456", version_number=3) + mock_file.delete_async = AsyncMock(return_value=None) + + # WHEN I call delete_async with version=5, version_only=True + result = await delete_async( + entity=mock_file, + version=5, + version_only=True, + synapse_client=MagicMock(), + ) + + # THEN a warning is logged about the version conflict + mock_client.logger.warning.assert_called_once() + warning_msg = mock_client.logger.warning.call_args[0][0] + assert "Version conflict" in warning_msg + assert "5" in warning_msg + assert "3" in warning_msg + assert result is None + + +class TestDeleteSyncWrapper: + """Tests for the synchronous delete() wrapper.""" + + @patch("synapseclient.operations.delete_operations.wrap_async_to_sync") + def test_delete_sync_calls_wrap_async_to_sync(self, mock_wrap): + """Test that the synchronous delete() calls wrap_async_to_sync.""" + from synapseclient.operations.delete_operations import delete + + # GIVEN a mock entity and wrap_async_to_sync + mock_entity = MagicMock() + mock_wrap.return_value = None + + # WHEN I call the sync delete + result = delete(entity=mock_entity, synapse_client=MagicMock()) + + # THEN wrap_async_to_sync is called + assert result is None + mock_wrap.assert_called_once() diff --git a/tests/unit/synapseclient/operations/unit_test_factory_operations.py b/tests/unit/synapseclient/operations/unit_test_factory_operations.py new file mode 100644 index 000000000..fa9e1cdba --- /dev/null +++ b/tests/unit/synapseclient/operations/unit_test_factory_operations.py @@ -0,0 +1,759 @@ +"""Unit tests for factory_operations (get/get_async) routing logic.""" + +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from synapseclient.core.constants import concrete_types +from synapseclient.core.exceptions import SynapseNotFoundError +from synapseclient.operations.factory_operations import ( + ActivityOptions, + FileOptions, + LinkOptions, + TableOptions, + get_async, +) + +# Patch paths for locally-imported functions inside get_async +_PATCH_GET_ENTITY_TYPE = "synapseclient.api.entity_services.get_entity_type" +_PATCH_GET_CHILD = "synapseclient.api.entity_services.get_child" +_PATCH_GET_BUNDLE = "synapseclient.api.entity_bundle_services_v2.get_entity_id_bundle2" +_PATCH_GET_VERSION_BUNDLE = ( + "synapseclient.api.entity_bundle_services_v2.get_entity_id_version_bundle2" +) + + +class TestGetAsyncInputValidation: + """Tests for input parameter validation in get_async.""" + + async def test_both_synapse_id_and_entity_name_raises_value_error(self): + """Test that providing both synapse_id and entity_name raises ValueError.""" + # WHEN/THEN get_async raises ValueError + with pytest.raises( + ValueError, match="Cannot specify both synapse_id and entity_name" + ): + await get_async( + synapse_id="syn123456", + entity_name="my_file", + synapse_client=MagicMock(), + ) + + async def test_neither_synapse_id_nor_entity_name_raises_value_error(self): + """Test that providing neither synapse_id nor entity_name raises ValueError.""" + # WHEN/THEN get_async raises ValueError + with pytest.raises( + ValueError, match="Must specify either synapse_id or entity_name" + ): + await get_async(synapse_client=MagicMock()) + + +class TestGetAsyncByEntityName: + """Tests for entity name-based lookup in get_async.""" + + @patch(_PATCH_GET_ENTITY_TYPE, new_callable=AsyncMock) + @patch(_PATCH_GET_CHILD, new_callable=AsyncMock) + async def test_get_by_entity_name_and_parent_id( + self, mock_get_child, mock_get_entity_type + ): + """Test get_async by entity_name with parent_id resolves the name first.""" + # GIVEN get_child returns a synapse ID + mock_get_child.return_value = "syn123456" + + # And get_entity_type returns a Project header + mock_header = MagicMock() + mock_header.type = concrete_types.PROJECT_ENTITY + mock_get_entity_type.return_value = mock_header + + from synapseclient.models import Project + + with patch.object(Project, "get_async", new_callable=AsyncMock) as mock_get: + mock_project = Project(id="syn123456", name="My Project") + mock_get.return_value = mock_project + + # WHEN I call get_async by entity_name + result = await get_async( + entity_name="My Project", + parent_id=None, + synapse_client=MagicMock(), + ) + + # THEN get_child is called to resolve the name + mock_get_child.assert_awaited_once() + call_kwargs = mock_get_child.call_args[1] + assert call_kwargs["entity_name"] == "My Project" + assert call_kwargs["parent_id"] is None + # And the entity is retrieved + mock_get.assert_awaited_once() + assert result is mock_project + + @patch(_PATCH_GET_CHILD, new_callable=AsyncMock) + async def test_get_by_entity_name_not_found_with_parent(self, mock_get_child): + """Test that entity_name not found with parent_id raises SynapseNotFoundError.""" + # GIVEN get_child returns None + mock_get_child.return_value = None + + # WHEN/THEN get_async raises SynapseNotFoundError + with pytest.raises(SynapseNotFoundError, match="not found in parent"): + await get_async( + entity_name="nonexistent", + parent_id="syn789", + synapse_client=MagicMock(), + ) + + @patch(_PATCH_GET_CHILD, new_callable=AsyncMock) + async def test_get_by_entity_name_project_not_found(self, mock_get_child): + """Test that project name not found raises SynapseNotFoundError.""" + # GIVEN get_child returns None + mock_get_child.return_value = None + + # WHEN/THEN get_async raises SynapseNotFoundError with project message + with pytest.raises(SynapseNotFoundError, match="Project with name"): + await get_async( + entity_name="Nonexistent Project", + parent_id=None, + synapse_client=MagicMock(), + ) + + +class TestGetAsyncFileEntityRoute: + """Tests for File entity routing in get_async.""" + + @patch(_PATCH_GET_ENTITY_TYPE, new_callable=AsyncMock) + async def test_get_file_entity_default_options(self, mock_get_entity_type): + """Test that File entity is retrieved with default options.""" + # GIVEN get_entity_type returns FILE_ENTITY + mock_header = MagicMock() + mock_header.type = concrete_types.FILE_ENTITY + mock_get_entity_type.return_value = mock_header + + from synapseclient.models import File + + with patch.object(File, "get_async", new_callable=AsyncMock) as mock_get: + mock_file = File(id="syn123456") + mock_get.return_value = mock_file + + # WHEN I call get_async for a file + result = await get_async(synapse_id="syn123456", synapse_client=MagicMock()) + + # THEN File.get_async is called + mock_get.assert_awaited_once() + assert result is mock_file + + @patch(_PATCH_GET_ENTITY_TYPE, new_callable=AsyncMock) + async def test_get_file_entity_with_file_options(self, mock_get_entity_type): + """Test that FileOptions are applied when retrieving a File.""" + # GIVEN get_entity_type returns FILE_ENTITY + mock_header = MagicMock() + mock_header.type = concrete_types.FILE_ENTITY + mock_get_entity_type.return_value = mock_header + + from synapseclient.models import File + + with patch.object(File, "get_async", new_callable=AsyncMock) as mock_get: + mock_file = File(id="syn123456") + mock_get.return_value = mock_file + + file_options = FileOptions( + download_file=False, + download_location="/tmp/downloads", + if_collision="overwrite.local", + ) + + # WHEN I call get_async with file options + result = await get_async( + synapse_id="syn123456", + file_options=file_options, + synapse_client=MagicMock(), + ) + + # THEN File is created with the file options applied + mock_get.assert_awaited_once() + assert result is mock_file + + @patch(_PATCH_GET_ENTITY_TYPE, new_callable=AsyncMock) + async def test_get_file_entity_with_version_number(self, mock_get_entity_type): + """Test that version_number is passed through to File entity.""" + # GIVEN get_entity_type returns FILE_ENTITY + mock_header = MagicMock() + mock_header.type = concrete_types.FILE_ENTITY + mock_get_entity_type.return_value = mock_header + + from synapseclient.models import File + + with patch.object(File, "get_async", new_callable=AsyncMock) as mock_get: + mock_file = File(id="syn123456", version_number=3) + mock_get.return_value = mock_file + + # WHEN I call get_async with a version number + result = await get_async( + synapse_id="syn123456", + version_number=3, + synapse_client=MagicMock(), + ) + + # THEN File.get_async is called + mock_get.assert_awaited_once() + assert result is mock_file + + @patch(_PATCH_GET_ENTITY_TYPE, new_callable=AsyncMock) + async def test_get_file_entity_with_activity_options(self, mock_get_entity_type): + """Test that ActivityOptions are passed through for File retrieval.""" + # GIVEN get_entity_type returns FILE_ENTITY + mock_header = MagicMock() + mock_header.type = concrete_types.FILE_ENTITY + mock_get_entity_type.return_value = mock_header + + from synapseclient.models import File + + with patch.object(File, "get_async", new_callable=AsyncMock) as mock_get: + mock_file = File(id="syn123456") + mock_get.return_value = mock_file + + activity_options = ActivityOptions(include_activity=True) + + # WHEN I call get_async with activity options + result = await get_async( + synapse_id="syn123456", + activity_options=activity_options, + synapse_client=MagicMock(), + ) + + # THEN File.get_async is called with include_activity=True + mock_get.assert_awaited_once() + call_kwargs = mock_get.call_args[1] + assert call_kwargs.get("include_activity") is True + assert result is mock_file + + +class TestGetAsyncLinkEntityRoute: + """Tests for Link entity routing in get_async.""" + + @patch(_PATCH_GET_ENTITY_TYPE, new_callable=AsyncMock) + async def test_get_link_entity_follow_link_true(self, mock_get_entity_type): + """Test that Link with follow_link=True follows the link.""" + # GIVEN get_entity_type returns LINK_ENTITY + mock_header = MagicMock() + mock_header.type = concrete_types.LINK_ENTITY + mock_get_entity_type.return_value = mock_header + + from synapseclient.models import Link + + with patch.object(Link, "get_async", new_callable=AsyncMock) as mock_get: + mock_target = MagicMock() + mock_get.return_value = mock_target + + link_options = LinkOptions(follow_link=True) + + # WHEN I call get_async with follow_link=True + result = await get_async( + synapse_id="syn123456", + link_options=link_options, + synapse_client=MagicMock(), + ) + + # THEN Link.get_async is called with follow_link=True + mock_get.assert_awaited_once() + call_kwargs = mock_get.call_args[1] + assert call_kwargs["follow_link"] is True + assert result is mock_target + + @patch(_PATCH_GET_ENTITY_TYPE, new_callable=AsyncMock) + async def test_get_link_entity_follow_link_false(self, mock_get_entity_type): + """Test that Link with follow_link=False returns the Link itself.""" + # GIVEN get_entity_type returns LINK_ENTITY + mock_header = MagicMock() + mock_header.type = concrete_types.LINK_ENTITY + mock_get_entity_type.return_value = mock_header + + from synapseclient.models import Link + + with patch.object(Link, "get_async", new_callable=AsyncMock) as mock_get: + mock_link = Link(id="syn123456") + mock_get.return_value = mock_link + + link_options = LinkOptions(follow_link=False) + + # WHEN I call get_async with follow_link=False + result = await get_async( + synapse_id="syn123456", + link_options=link_options, + synapse_client=MagicMock(), + ) + + # THEN Link.get_async is called with follow_link=False + mock_get.assert_awaited_once() + call_kwargs = mock_get.call_args[1] + assert call_kwargs["follow_link"] is False + assert result is mock_link + + @patch(_PATCH_GET_ENTITY_TYPE, new_callable=AsyncMock) + async def test_get_link_entity_default_follows_link(self, mock_get_entity_type): + """Test that Link with default options follows the link (follow_link=True).""" + # GIVEN get_entity_type returns LINK_ENTITY + mock_header = MagicMock() + mock_header.type = concrete_types.LINK_ENTITY + mock_get_entity_type.return_value = mock_header + + from synapseclient.models import Link + + with patch.object(Link, "get_async", new_callable=AsyncMock) as mock_get: + mock_target = MagicMock() + mock_get.return_value = mock_target + + # WHEN I call get_async with default link options + result = await get_async(synapse_id="syn123456", synapse_client=MagicMock()) + + # THEN Link.get_async is called with follow_link=True (default) + mock_get.assert_awaited_once() + call_kwargs = mock_get.call_args[1] + assert call_kwargs["follow_link"] is True + assert result is mock_target + + +class TestGetAsyncTableEntityRoute: + """Tests for table-like entity routing in get_async.""" + + @patch(_PATCH_GET_ENTITY_TYPE, new_callable=AsyncMock) + async def test_get_table_entity_with_table_options(self, mock_get_entity_type): + """Test that Table entity is retrieved with table options.""" + # GIVEN get_entity_type returns TABLE_ENTITY + mock_header = MagicMock() + mock_header.type = concrete_types.TABLE_ENTITY + mock_get_entity_type.return_value = mock_header + + from synapseclient.models import Table + + with patch.object(Table, "get_async", new_callable=AsyncMock) as mock_get: + mock_table = Table(id="syn123456") + mock_get.return_value = mock_table + + table_options = TableOptions(include_columns=True) + + # WHEN I call get_async with table options + result = await get_async( + synapse_id="syn123456", + table_options=table_options, + synapse_client=MagicMock(), + ) + + # THEN Table.get_async is called with include_columns=True + mock_get.assert_awaited_once() + call_kwargs = mock_get.call_args[1] + assert call_kwargs["include_columns"] is True + assert result is mock_table + + @patch(_PATCH_GET_ENTITY_TYPE, new_callable=AsyncMock) + async def test_get_table_entity_without_columns(self, mock_get_entity_type): + """Test that Table entity can be retrieved without columns.""" + # GIVEN get_entity_type returns TABLE_ENTITY + mock_header = MagicMock() + mock_header.type = concrete_types.TABLE_ENTITY + mock_get_entity_type.return_value = mock_header + + from synapseclient.models import Table + + with patch.object(Table, "get_async", new_callable=AsyncMock) as mock_get: + mock_table = Table(id="syn123456") + mock_get.return_value = mock_table + + table_options = TableOptions(include_columns=False) + + # WHEN I call get_async with include_columns=False + result = await get_async( + synapse_id="syn123456", + table_options=table_options, + synapse_client=MagicMock(), + ) + + # THEN Table.get_async is called with include_columns=False + mock_get.assert_awaited_once() + call_kwargs = mock_get.call_args[1] + assert call_kwargs["include_columns"] is False + assert result is mock_table + + @patch(_PATCH_GET_ENTITY_TYPE, new_callable=AsyncMock) + async def test_get_table_with_version_number(self, mock_get_entity_type): + """Test that version_number is passed through for table entity.""" + # GIVEN get_entity_type returns TABLE_ENTITY + mock_header = MagicMock() + mock_header.type = concrete_types.TABLE_ENTITY + mock_get_entity_type.return_value = mock_header + + from synapseclient.models import Table + + with patch.object(Table, "get_async", new_callable=AsyncMock) as mock_get: + mock_table = Table(id="syn123456", version_number=5) + mock_get.return_value = mock_table + + # WHEN I call get_async with version_number + result = await get_async( + synapse_id="syn123456", + version_number=5, + synapse_client=MagicMock(), + ) + + # THEN Table.get_async is called + mock_get.assert_awaited_once() + assert result is mock_table + + @patch(_PATCH_GET_ENTITY_TYPE, new_callable=AsyncMock) + async def test_get_dataset_routes_to_table_handler(self, mock_get_entity_type): + """Test that Dataset routes to table-like handler.""" + # GIVEN get_entity_type returns DATASET_ENTITY + mock_header = MagicMock() + mock_header.type = concrete_types.DATASET_ENTITY + mock_get_entity_type.return_value = mock_header + + from synapseclient.models import Dataset + + with patch.object(Dataset, "get_async", new_callable=AsyncMock) as mock_get: + mock_dataset = Dataset(id="syn123456") + mock_get.return_value = mock_dataset + + # WHEN I call get_async + result = await get_async(synapse_id="syn123456", synapse_client=MagicMock()) + + # THEN Dataset.get_async is called + mock_get.assert_awaited_once() + assert result is mock_dataset + + @patch(_PATCH_GET_ENTITY_TYPE, new_callable=AsyncMock) + async def test_get_entityview_routes_to_table_handler(self, mock_get_entity_type): + """Test that EntityView routes to table-like handler.""" + # GIVEN get_entity_type returns ENTITY_VIEW + mock_header = MagicMock() + mock_header.type = concrete_types.ENTITY_VIEW + mock_get_entity_type.return_value = mock_header + + from synapseclient.models import EntityView + + with patch.object(EntityView, "get_async", new_callable=AsyncMock) as mock_get: + mock_view = EntityView(id="syn123456") + mock_get.return_value = mock_view + + # WHEN I call get_async + result = await get_async(synapse_id="syn123456", synapse_client=MagicMock()) + + # THEN EntityView.get_async is called + mock_get.assert_awaited_once() + assert result is mock_view + + @patch(_PATCH_GET_ENTITY_TYPE, new_callable=AsyncMock) + async def test_get_materialized_view_routes_to_table_handler( + self, mock_get_entity_type + ): + """Test that MaterializedView routes to table-like handler.""" + # GIVEN get_entity_type returns MATERIALIZED_VIEW + mock_header = MagicMock() + mock_header.type = concrete_types.MATERIALIZED_VIEW + mock_get_entity_type.return_value = mock_header + + from synapseclient.models import MaterializedView + + with patch.object( + MaterializedView, "get_async", new_callable=AsyncMock + ) as mock_get: + mock_mv = MaterializedView(id="syn123456") + mock_get.return_value = mock_mv + + # WHEN I call get_async + result = await get_async(synapse_id="syn123456", synapse_client=MagicMock()) + + # THEN MaterializedView.get_async is called + mock_get.assert_awaited_once() + assert result is mock_mv + + @patch(_PATCH_GET_ENTITY_TYPE, new_callable=AsyncMock) + async def test_get_virtual_table_routes_to_table_handler( + self, mock_get_entity_type + ): + """Test that VirtualTable routes to table-like handler.""" + # GIVEN get_entity_type returns VIRTUAL_TABLE + mock_header = MagicMock() + mock_header.type = concrete_types.VIRTUAL_TABLE + mock_get_entity_type.return_value = mock_header + + from synapseclient.models import VirtualTable + + with patch.object( + VirtualTable, "get_async", new_callable=AsyncMock + ) as mock_get: + mock_vt = VirtualTable(id="syn123456") + mock_get.return_value = mock_vt + + # WHEN I call get_async + result = await get_async(synapse_id="syn123456", synapse_client=MagicMock()) + + # THEN VirtualTable.get_async is called + mock_get.assert_awaited_once() + assert result is mock_vt + + @patch(_PATCH_GET_ENTITY_TYPE, new_callable=AsyncMock) + async def test_get_table_with_activity_options(self, mock_get_entity_type): + """Test that ActivityOptions are passed through for table retrieval.""" + # GIVEN get_entity_type returns TABLE_ENTITY + mock_header = MagicMock() + mock_header.type = concrete_types.TABLE_ENTITY + mock_get_entity_type.return_value = mock_header + + from synapseclient.models import Table + + with patch.object(Table, "get_async", new_callable=AsyncMock) as mock_get: + mock_table = Table(id="syn123456") + mock_get.return_value = mock_table + + activity_options = ActivityOptions(include_activity=True) + + # WHEN I call get_async with activity options + result = await get_async( + synapse_id="syn123456", + activity_options=activity_options, + synapse_client=MagicMock(), + ) + + # THEN Table.get_async is called with include_activity=True + mock_get.assert_awaited_once() + call_kwargs = mock_get.call_args[1] + assert call_kwargs.get("include_activity") is True + assert result is mock_table + + +class TestGetAsyncSimpleEntityRoute: + """Tests for simple entity (Project, Folder) routing in get_async.""" + + @patch(_PATCH_GET_ENTITY_TYPE, new_callable=AsyncMock) + async def test_get_project_entity(self, mock_get_entity_type): + """Test that Project entity is retrieved correctly.""" + # GIVEN get_entity_type returns PROJECT_ENTITY + mock_header = MagicMock() + mock_header.type = concrete_types.PROJECT_ENTITY + mock_get_entity_type.return_value = mock_header + + from synapseclient.models import Project + + with patch.object(Project, "get_async", new_callable=AsyncMock) as mock_get: + mock_project = Project(id="syn123456") + mock_get.return_value = mock_project + + # WHEN I call get_async + result = await get_async(synapse_id="syn123456", synapse_client=MagicMock()) + + # THEN Project.get_async is called + mock_get.assert_awaited_once() + assert result is mock_project + + @patch(_PATCH_GET_ENTITY_TYPE, new_callable=AsyncMock) + async def test_get_folder_entity(self, mock_get_entity_type): + """Test that Folder entity is retrieved correctly.""" + # GIVEN get_entity_type returns FOLDER_ENTITY + mock_header = MagicMock() + mock_header.type = concrete_types.FOLDER_ENTITY + mock_get_entity_type.return_value = mock_header + + from synapseclient.models import Folder + + with patch.object(Folder, "get_async", new_callable=AsyncMock) as mock_get: + mock_folder = Folder(id="syn123456") + mock_get.return_value = mock_folder + + # WHEN I call get_async + result = await get_async(synapse_id="syn123456", synapse_client=MagicMock()) + + # THEN Folder.get_async is called + mock_get.assert_awaited_once() + assert result is mock_folder + + +class TestGetAsyncEntityInstance: + """Tests for passing entity instances directly to get_async.""" + + async def test_get_with_file_entity_instance(self): + """Test that passing a File instance calls _handle_entity_instance.""" + # GIVEN a mock File entity instance + from synapseclient.models import File + + mock_file = File(id="syn123456") + mock_file.get_async = AsyncMock(return_value=mock_file) + + # WHEN I call get_async with the entity instance + result = await get_async(synapse_id=mock_file, synapse_client=MagicMock()) + + # THEN the entity's get_async is called + mock_file.get_async.assert_awaited_once() + assert result is mock_file + + async def test_get_with_file_entity_instance_applies_file_options(self): + """Test that FileOptions are applied when passing a File instance.""" + # GIVEN a mock File entity instance + from synapseclient.models import File + + mock_file = File(id="syn123456") + mock_file.get_async = AsyncMock(return_value=mock_file) + + file_options = FileOptions( + download_file=False, + download_location="/tmp/downloads", + if_collision="overwrite.local", + ) + + # WHEN I call get_async with file options + result = await get_async( + synapse_id=mock_file, + file_options=file_options, + synapse_client=MagicMock(), + ) + + # THEN the file options are applied to the entity + assert mock_file.download_file is False + assert mock_file.path == "/tmp/downloads" + assert mock_file.if_collision == "overwrite.local" + mock_file.get_async.assert_awaited_once() + assert result is mock_file + + async def test_get_with_entity_instance_applies_version_number(self): + """Test that version_number is set on the entity instance.""" + # GIVEN a mock File entity instance + from synapseclient.models import File + + mock_file = File(id="syn123456") + mock_file.get_async = AsyncMock(return_value=mock_file) + + # WHEN I call get_async with version_number + result = await get_async( + synapse_id=mock_file, + version_number=5, + synapse_client=MagicMock(), + ) + + # THEN the version_number is set on the entity + assert mock_file.version_number == 5 + mock_file.get_async.assert_awaited_once() + assert result is mock_file + + async def test_get_with_link_entity_instance(self): + """Test that passing a Link instance applies link options.""" + # GIVEN a mock Link entity instance + from synapseclient.models import Link + + mock_link = Link(id="syn123456") + mock_link.get_async = AsyncMock(return_value=mock_link) + + link_options = LinkOptions(follow_link=False) + + # WHEN I call get_async with link options + result = await get_async( + synapse_id=mock_link, + link_options=link_options, + synapse_client=MagicMock(), + ) + + # THEN get_async is called with follow_link=False + mock_link.get_async.assert_awaited_once() + call_kwargs = mock_link.get_async.call_args[1] + assert call_kwargs["follow_link"] is False + assert result is mock_link + + async def test_get_with_table_entity_instance_applies_table_options(self): + """Test that TableOptions are applied when passing a Table instance.""" + # GIVEN a mock Table entity instance + from synapseclient.models import Table + + mock_table = Table(id="syn123456") + mock_table.get_async = AsyncMock(return_value=mock_table) + + table_options = TableOptions(include_columns=False) + + # WHEN I call get_async with table options + result = await get_async( + synapse_id=mock_table, + table_options=table_options, + synapse_client=MagicMock(), + ) + + # THEN get_async is called with include_columns=False + mock_table.get_async.assert_awaited_once() + call_kwargs = mock_table.get_async.call_args[1] + assert call_kwargs["include_columns"] is False + assert result is mock_table + + +class TestGetAsyncUnknownEntityType: + """Tests for unknown/fallback entity type handling.""" + + @patch("synapseclient.Synapse.get_client") + @patch(_PATCH_GET_BUNDLE, new_callable=AsyncMock) + @patch(_PATCH_GET_ENTITY_TYPE, new_callable=AsyncMock) + async def test_get_unknown_entity_type_falls_back_to_bundle( + self, mock_get_entity_type, mock_get_bundle, mock_get_client + ): + """Test that unknown entity type falls back to entity bundle.""" + # GIVEN get_entity_type returns an unknown type + mock_header = MagicMock() + mock_header.type = "org.sagebionetworks.repo.model.UnknownEntity" + mock_get_entity_type.return_value = mock_header + + mock_client = MagicMock() + mock_get_client.return_value = mock_client + + mock_bundle = {"entity": {"id": "syn123456"}} + mock_get_bundle.return_value = mock_bundle + + # WHEN I call get_async + result = await get_async(synapse_id="syn123456", synapse_client=MagicMock()) + + # THEN a warning is logged and the bundle is returned + mock_client.logger.warning.assert_called_once() + mock_get_bundle.assert_awaited_once() + assert result == mock_bundle + + @patch("synapseclient.Synapse.get_client") + @patch(_PATCH_GET_VERSION_BUNDLE, new_callable=AsyncMock) + @patch(_PATCH_GET_ENTITY_TYPE, new_callable=AsyncMock) + async def test_get_unknown_entity_type_with_version_uses_version_bundle( + self, mock_get_entity_type, mock_get_version_bundle, mock_get_client + ): + """Test that unknown entity type with version uses versioned bundle API.""" + # GIVEN get_entity_type returns an unknown type + mock_header = MagicMock() + mock_header.type = "org.sagebionetworks.repo.model.UnknownEntity" + mock_get_entity_type.return_value = mock_header + + mock_client = MagicMock() + mock_get_client.return_value = mock_client + + mock_bundle = {"entity": {"id": "syn123456", "version": 3}} + mock_get_version_bundle.return_value = mock_bundle + + # WHEN I call get_async with version_number + result = await get_async( + synapse_id="syn123456", + version_number=3, + synapse_client=MagicMock(), + ) + + # THEN the versioned bundle API is called + mock_get_version_bundle.assert_awaited_once() + call_kwargs = mock_get_version_bundle.call_args[1] + assert call_kwargs["entity_id"] == "syn123456" + assert call_kwargs["version"] == 3 + assert result == mock_bundle + + +class TestGetSyncWrapper: + """Tests for the synchronous get() wrapper.""" + + @patch("synapseclient.operations.factory_operations.wrap_async_to_sync") + def test_get_sync_calls_wrap_async_to_sync(self, mock_wrap): + """Test that the synchronous get() calls wrap_async_to_sync.""" + from synapseclient.operations.factory_operations import get + + # GIVEN a mock wrap_async_to_sync + mock_entity = MagicMock() + mock_wrap.return_value = mock_entity + + # WHEN I call the sync get + result = get(synapse_id="syn123456", synapse_client=MagicMock()) + + # THEN wrap_async_to_sync is called + assert result is mock_entity + mock_wrap.assert_called_once() diff --git a/tests/unit/synapseclient/operations/unit_test_store_operations.py b/tests/unit/synapseclient/operations/unit_test_store_operations.py new file mode 100644 index 000000000..34d439c1f --- /dev/null +++ b/tests/unit/synapseclient/operations/unit_test_store_operations.py @@ -0,0 +1,649 @@ +"""Unit tests for store_operations routing logic.""" + +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from synapseclient.models.services.storable_entity_components import FailureStrategy +from synapseclient.operations.store_operations import ( + StoreContainerOptions, + StoreFileOptions, + StoreGridOptions, + StoreJSONSchemaOptions, + StoreTableOptions, + store_async, +) + + +class TestStoreFileEntityRoute: + """Tests for File entity routing in store_async.""" + + async def test_store_file_entity_default_options(self): + """Test that a File entity is routed to _handle_store_file_entity.""" + # GIVEN a mock File entity + from synapseclient.models import File + + mock_file = File(path="/tmp/test.txt", parent_id="syn123") + mock_file.store_async = AsyncMock(return_value=mock_file) + + # WHEN I call store_async + result = await store_async(entity=mock_file, synapse_client=MagicMock()) + + # THEN the file's store_async is called with default params + mock_file.store_async.assert_awaited_once() + assert result is mock_file + + async def test_store_file_entity_with_file_options(self): + """Test that StoreFileOptions are applied to the File entity before storing.""" + # GIVEN a mock File entity with file options + from synapseclient.models import File + + mock_file = File(path="/tmp/test.txt", parent_id="syn123") + mock_file.store_async = AsyncMock(return_value=mock_file) + + file_options = StoreFileOptions( + synapse_store=False, + content_type="text/plain", + merge_existing_annotations=True, + associate_activity_to_new_version=False, + ) + + # WHEN I call store_async with file options + result = await store_async( + entity=mock_file, file_options=file_options, synapse_client=MagicMock() + ) + + # THEN the file options should be applied to the entity + assert mock_file.synapse_store is False + assert mock_file.content_type == "text/plain" + assert mock_file.merge_existing_annotations is True + assert mock_file.associate_activity_to_new_version is False + mock_file.store_async.assert_awaited_once() + assert result is mock_file + + async def test_store_file_entity_with_parent(self): + """Test that a parent is passed through when storing a File.""" + # GIVEN a mock File entity and a parent Folder + from synapseclient.models import File, Folder + + mock_file = File(path="/tmp/test.txt") + mock_file.store_async = AsyncMock(return_value=mock_file) + parent_folder = Folder(id="syn456") + + # WHEN I call store_async with a parent + result = await store_async( + entity=mock_file, parent=parent_folder, synapse_client=MagicMock() + ) + + # THEN store_async is called with the parent + call_kwargs = mock_file.store_async.call_args[1] + assert call_kwargs["parent"] is parent_folder + assert result is mock_file + + async def test_store_recordset_routes_to_file_handler(self): + """Test that RecordSet also routes through the file entity handler.""" + # GIVEN a mock RecordSet entity + from synapseclient.models import RecordSet + + mock_recordset = RecordSet(id="syn789") + mock_recordset.store_async = AsyncMock(return_value=mock_recordset) + + # WHEN I call store_async + result = await store_async(entity=mock_recordset, synapse_client=MagicMock()) + + # THEN the recordset's store_async is called + mock_recordset.store_async.assert_awaited_once() + assert result is mock_recordset + + +class TestStoreContainerEntityRoute: + """Tests for container entity (Project, Folder) routing in store_async.""" + + async def test_store_project_default_options(self): + """Test that a Project entity is stored with default failure strategy.""" + # GIVEN a mock Project entity + from synapseclient.models import Project + + mock_project = Project(name="Test Project") + mock_project.store_async = AsyncMock(return_value=mock_project) + + # WHEN I call store_async + result = await store_async(entity=mock_project, synapse_client=MagicMock()) + + # THEN store_async is called with LOG_EXCEPTION as the default failure strategy + call_kwargs = mock_project.store_async.call_args[1] + assert call_kwargs["failure_strategy"] == FailureStrategy.LOG_EXCEPTION + assert result is mock_project + + async def test_store_folder_with_raise_exception_strategy(self): + """Test that a Folder entity uses RAISE_EXCEPTION failure strategy.""" + # GIVEN a mock Folder entity with container options + from synapseclient.models import Folder + + mock_folder = Folder(name="Test Folder", parent_id="syn123") + mock_folder.store_async = AsyncMock(return_value=mock_folder) + container_options = StoreContainerOptions( + failure_strategy=FailureStrategy.RAISE_EXCEPTION + ) + + # WHEN I call store_async with container options + result = await store_async( + entity=mock_folder, + container_options=container_options, + synapse_client=MagicMock(), + ) + + # THEN store_async is called with RAISE_EXCEPTION strategy + call_kwargs = mock_folder.store_async.call_args[1] + assert call_kwargs["failure_strategy"] == FailureStrategy.RAISE_EXCEPTION + assert result is mock_folder + + async def test_store_folder_with_string_failure_strategy(self): + """Test that a string failure strategy is converted to enum.""" + # GIVEN a mock Folder entity with string failure strategy + from synapseclient.models import Folder + + mock_folder = Folder(name="Test Folder", parent_id="syn123") + mock_folder.store_async = AsyncMock(return_value=mock_folder) + container_options = StoreContainerOptions(failure_strategy="RAISE_EXCEPTION") + + # WHEN I call store_async with string failure strategy + result = await store_async( + entity=mock_folder, + container_options=container_options, + synapse_client=MagicMock(), + ) + + # THEN store_async is called with RAISE_EXCEPTION enum + call_kwargs = mock_folder.store_async.call_args[1] + assert call_kwargs["failure_strategy"] == FailureStrategy.RAISE_EXCEPTION + assert result is mock_folder + + async def test_store_folder_with_parent(self): + """Test that a parent is passed through when storing a Folder.""" + # GIVEN a mock Folder entity and a parent Project + from synapseclient.models import Folder, Project + + mock_folder = Folder(name="Test Folder") + mock_folder.store_async = AsyncMock(return_value=mock_folder) + parent_project = Project(id="syn456") + + # WHEN I call store_async with parent + result = await store_async( + entity=mock_folder, parent=parent_project, synapse_client=MagicMock() + ) + + # THEN store_async is called with the parent + call_kwargs = mock_folder.store_async.call_args[1] + assert call_kwargs["parent"] is parent_project + assert result is mock_folder + + async def test_store_project_ignores_parent(self): + """Test that Project ignores parent parameter (projects have no parent).""" + # GIVEN a mock Project entity + from synapseclient.models import Project + + mock_project = Project(name="Test Project") + mock_project.store_async = AsyncMock(return_value=mock_project) + + # WHEN I call store_async + result = await store_async(entity=mock_project, synapse_client=MagicMock()) + + # THEN store_async is called without parent kwarg + call_kwargs = mock_project.store_async.call_args[1] + assert "parent" not in call_kwargs + assert result is mock_project + + +class TestStoreTableEntityRoute: + """Tests for table-like entity routing in store_async.""" + + async def test_store_table_default_options(self): + """Test that a Table entity is stored with default table options.""" + # GIVEN a mock Table entity + from synapseclient.models import Table + + mock_table = Table(name="Test Table", parent_id="syn123") + mock_table.store_async = AsyncMock(return_value=mock_table) + + # WHEN I call store_async with no table options + result = await store_async(entity=mock_table, synapse_client=MagicMock()) + + # THEN store_async is called with default dry_run=False and job_timeout=600 + call_kwargs = mock_table.store_async.call_args[1] + assert call_kwargs["dry_run"] is False + assert call_kwargs["job_timeout"] == 600 + assert result is mock_table + + async def test_store_table_with_options(self): + """Test that StoreTableOptions are applied to table entity.""" + # GIVEN a mock Table entity with table options + from synapseclient.models import Table + + mock_table = Table(name="Test Table", parent_id="syn123") + mock_table.store_async = AsyncMock(return_value=mock_table) + table_options = StoreTableOptions(dry_run=True, job_timeout=300) + + # WHEN I call store_async with table options + result = await store_async( + entity=mock_table, + table_options=table_options, + synapse_client=MagicMock(), + ) + + # THEN store_async is called with the specified options + call_kwargs = mock_table.store_async.call_args[1] + assert call_kwargs["dry_run"] is True + assert call_kwargs["job_timeout"] == 300 + assert result is mock_table + + async def test_store_dataset_routes_to_table_handler(self): + """Test that Dataset routes through the table entity handler.""" + # GIVEN a mock Dataset entity + from synapseclient.models import Dataset + + mock_dataset = Dataset(name="Test Dataset", parent_id="syn123") + mock_dataset.store_async = AsyncMock(return_value=mock_dataset) + + # WHEN I call store_async + result = await store_async(entity=mock_dataset, synapse_client=MagicMock()) + + # THEN store_async is called with table-like defaults + call_kwargs = mock_dataset.store_async.call_args[1] + assert call_kwargs["dry_run"] is False + assert result is mock_dataset + + async def test_store_entityview_routes_to_table_handler(self): + """Test that EntityView routes through the table entity handler.""" + # GIVEN a mock EntityView entity + from synapseclient.models import EntityView + + mock_view = EntityView(name="Test View", parent_id="syn123") + mock_view.store_async = AsyncMock(return_value=mock_view) + + # WHEN I call store_async + result = await store_async(entity=mock_view, synapse_client=MagicMock()) + + # THEN store_async is called + mock_view.store_async.assert_awaited_once() + assert result is mock_view + + async def test_store_materialized_view_routes_to_table_handler(self): + """Test that MaterializedView routes through the table entity handler.""" + # GIVEN a mock MaterializedView entity + from synapseclient.models import MaterializedView + + mock_mv = MaterializedView(name="Test MV", parent_id="syn123") + mock_mv.store_async = AsyncMock(return_value=mock_mv) + + # WHEN I call store_async + result = await store_async(entity=mock_mv, synapse_client=MagicMock()) + + # THEN store_async is called + mock_mv.store_async.assert_awaited_once() + assert result is mock_mv + + async def test_store_virtual_table_routes_to_table_handler(self): + """Test that VirtualTable routes through the table entity handler.""" + # GIVEN a mock VirtualTable entity + from synapseclient.models import VirtualTable + + mock_vt = VirtualTable(name="Test VT", parent_id="syn123") + mock_vt.store_async = AsyncMock(return_value=mock_vt) + + # WHEN I call store_async + result = await store_async(entity=mock_vt, synapse_client=MagicMock()) + + # THEN store_async is called + mock_vt.store_async.assert_awaited_once() + assert result is mock_vt + + +class TestStoreLinkEntityRoute: + """Tests for Link entity routing in store_async.""" + + async def test_store_link_entity(self): + """Test that a Link entity is routed correctly.""" + # GIVEN a mock Link entity + from synapseclient.models import Link + + mock_link = Link(name="Test Link", parent_id="syn123", target_id="syn456") + mock_link.store_async = AsyncMock(return_value=mock_link) + + # WHEN I call store_async + result = await store_async(entity=mock_link, synapse_client=MagicMock()) + + # THEN store_async is called + mock_link.store_async.assert_awaited_once() + assert result is mock_link + + async def test_store_link_entity_with_parent(self): + """Test that a parent is passed through when storing a Link.""" + # GIVEN a mock Link entity and a parent Folder + from synapseclient.models import Folder, Link + + mock_link = Link(name="Test Link", target_id="syn789") + mock_link.store_async = AsyncMock(return_value=mock_link) + parent_folder = Folder(id="syn456") + + # WHEN I call store_async with a parent + result = await store_async( + entity=mock_link, parent=parent_folder, synapse_client=MagicMock() + ) + + # THEN store_async is called with the parent + call_kwargs = mock_link.store_async.call_args[1] + assert call_kwargs["parent"] is parent_folder + assert result is mock_link + + +class TestStoreJSONSchemaRoute: + """Tests for JSONSchema entity routing in store_async.""" + + async def test_store_json_schema_with_options(self): + """Test that JSONSchema is stored with schema options.""" + # GIVEN a mock JSONSchema entity with schema options + from synapseclient.models import JSONSchema + + mock_schema = JSONSchema(organization_name="testorg", name="testschema") + mock_schema.store_async = AsyncMock(return_value=mock_schema) + json_schema_options = StoreJSONSchemaOptions( + schema_body={"type": "object"}, + version="1.0.0", + dry_run=False, + ) + + # WHEN I call store_async with json schema options + result = await store_async( + entity=mock_schema, + json_schema_options=json_schema_options, + synapse_client=MagicMock(), + ) + + # THEN store_async is called with schema_body, version, and dry_run + call_kwargs = mock_schema.store_async.call_args[1] + assert call_kwargs["schema_body"] == {"type": "object"} + assert call_kwargs["version"] == "1.0.0" + assert call_kwargs["dry_run"] is False + assert result is mock_schema + + async def test_store_json_schema_with_dry_run(self): + """Test that JSONSchema respects dry_run option.""" + # GIVEN a mock JSONSchema entity with dry_run=True + from synapseclient.models import JSONSchema + + mock_schema = JSONSchema(organization_name="testorg", name="testschema") + mock_schema.store_async = AsyncMock(return_value=mock_schema) + json_schema_options = StoreJSONSchemaOptions( + schema_body={"type": "string"}, + dry_run=True, + ) + + # WHEN I call store_async with dry_run + result = await store_async( + entity=mock_schema, + json_schema_options=json_schema_options, + synapse_client=MagicMock(), + ) + + # THEN store_async is called with dry_run=True + call_kwargs = mock_schema.store_async.call_args[1] + assert call_kwargs["dry_run"] is True + assert result is mock_schema + + async def test_store_json_schema_without_options_raises_value_error(self): + """Test that JSONSchema without options raises ValueError.""" + # GIVEN a mock JSONSchema entity without options + from synapseclient.models import JSONSchema + + mock_schema = JSONSchema(organization_name="testorg", name="testschema") + + # WHEN/THEN store_async raises ValueError + with pytest.raises( + ValueError, match="json_schema_options with schema_body is required" + ): + await store_async(entity=mock_schema, synapse_client=MagicMock()) + + async def test_store_json_schema_with_empty_schema_body_raises_value_error(self): + """Test that JSONSchema with falsy schema_body raises ValueError.""" + # GIVEN a mock JSONSchema entity with empty schema_body + from synapseclient.models import JSONSchema + + mock_schema = JSONSchema(organization_name="testorg", name="testschema") + json_schema_options = StoreJSONSchemaOptions(schema_body={}) + + # WHEN/THEN store_async raises ValueError for empty body + with pytest.raises( + ValueError, match="json_schema_options with schema_body is required" + ): + await store_async( + entity=mock_schema, + json_schema_options=json_schema_options, + synapse_client=MagicMock(), + ) + + +class TestStoreTeamRoute: + """Tests for Team entity routing in store_async.""" + + async def test_store_team_without_id_calls_create(self): + """Test that a Team without an ID calls create_async.""" + # GIVEN a mock Team entity without an ID + from synapseclient.models import Team + + mock_team = Team(name="Test Team") + mock_team.create_async = AsyncMock(return_value=mock_team) + mock_team.store_async = AsyncMock(return_value=mock_team) + + # WHEN I call store_async + result = await store_async(entity=mock_team, synapse_client=MagicMock()) + + # THEN create_async is called (not store_async) + mock_team.create_async.assert_awaited_once() + mock_team.store_async.assert_not_awaited() + assert result is mock_team + + async def test_store_team_with_id_calls_store(self): + """Test that a Team with an ID calls store_async (update).""" + # GIVEN a mock Team entity with an ID + from synapseclient.models import Team + + mock_team = Team(id=12345, name="Test Team") + mock_team.store_async = AsyncMock(return_value=mock_team) + mock_team.create_async = AsyncMock(return_value=mock_team) + + # WHEN I call store_async + result = await store_async(entity=mock_team, synapse_client=MagicMock()) + + # THEN store_async is called (not create_async) + mock_team.store_async.assert_awaited_once() + mock_team.create_async.assert_not_awaited() + assert result is mock_team + + +class TestStoreEvaluationRoute: + """Tests for Evaluation entity routing in store_async.""" + + async def test_store_evaluation_entity(self): + """Test that an Evaluation entity routes to store_async.""" + # GIVEN a mock Evaluation entity + from synapseclient.models import Evaluation + + mock_eval = Evaluation(name="Test Eval", content_source="syn123") + mock_eval.store_async = AsyncMock(return_value=mock_eval) + + # WHEN I call store_async + result = await store_async(entity=mock_eval, synapse_client=MagicMock()) + + # THEN store_async is called + mock_eval.store_async.assert_awaited_once() + assert result is mock_eval + + +class TestStoreSchemaOrganizationRoute: + """Tests for SchemaOrganization entity routing in store_async.""" + + async def test_store_schema_organization(self): + """Test that SchemaOrganization routes to store_async.""" + # GIVEN a mock SchemaOrganization entity + from synapseclient.models import SchemaOrganization + + mock_org = SchemaOrganization(name="testorg") + mock_org.store_async = AsyncMock(return_value=mock_org) + + # WHEN I call store_async + result = await store_async(entity=mock_org, synapse_client=MagicMock()) + + # THEN store_async is called + mock_org.store_async.assert_awaited_once() + assert result is mock_org + + +class TestStoreFormRoute: + """Tests for Form entity routing in store_async.""" + + async def test_store_form_group_calls_create_or_get(self): + """Test that FormGroup routes to create_or_get_async.""" + # GIVEN a mock FormGroup entity + from synapseclient.models import FormGroup + + mock_form_group = FormGroup(name="Test Form Group") + mock_form_group.create_or_get_async = AsyncMock(return_value=mock_form_group) + + # WHEN I call store_async + result = await store_async(entity=mock_form_group, synapse_client=MagicMock()) + + # THEN create_or_get_async is called + mock_form_group.create_or_get_async.assert_awaited_once() + assert result is mock_form_group + + async def test_store_form_data_calls_create_or_get(self): + """Test that FormData routes to create_or_get_async.""" + # GIVEN a mock FormData entity + from synapseclient.models import FormData + + mock_form_data = FormData(name="Test Form Data", group_id="test_group_id") + mock_form_data.create_or_get_async = AsyncMock(return_value=mock_form_data) + + # WHEN I call store_async + result = await store_async(entity=mock_form_data, synapse_client=MagicMock()) + + # THEN create_or_get_async is called + mock_form_data.create_or_get_async.assert_awaited_once() + assert result is mock_form_data + + +class TestStoreAgentSessionRoute: + """Tests for AgentSession entity routing in store_async.""" + + async def test_store_agent_session_calls_update(self): + """Test that AgentSession routes to update_async.""" + # GIVEN a mock AgentSession entity + from synapseclient.models import AgentSession + + mock_session = AgentSession(id="session123") + mock_session.update_async = AsyncMock(return_value=mock_session) + + # WHEN I call store_async + result = await store_async(entity=mock_session, synapse_client=MagicMock()) + + # THEN update_async is called + mock_session.update_async.assert_awaited_once() + assert result is mock_session + + +class TestStoreCurationTaskRoute: + """Tests for CurationTask entity routing in store_async.""" + + async def test_store_curation_task(self): + """Test that CurationTask routes to store_async.""" + # GIVEN a mock CurationTask entity + from synapseclient.models import CurationTask + + mock_task = CurationTask(project_id="syn123") + mock_task.store_async = AsyncMock(return_value=mock_task) + + # WHEN I call store_async + result = await store_async(entity=mock_task, synapse_client=MagicMock()) + + # THEN store_async is called + mock_task.store_async.assert_awaited_once() + assert result is mock_task + + +class TestStoreGridRoute: + """Tests for Grid entity routing in store_async.""" + + async def test_store_grid_default_options(self): + """Test that Grid entity is stored with default grid options.""" + # GIVEN a mock Grid entity + from synapseclient.models import Grid + + mock_grid = Grid(record_set_id="syn123") + mock_grid.create_async = AsyncMock(return_value=mock_grid) + + # WHEN I call store_async with no grid options + result = await store_async(entity=mock_grid, synapse_client=MagicMock()) + + # THEN create_async is called with defaults + call_kwargs = mock_grid.create_async.call_args[1] + assert call_kwargs["attach_to_previous_session"] is False + assert call_kwargs["timeout"] == 120 + assert result is mock_grid + + async def test_store_grid_with_options(self): + """Test that StoreGridOptions are applied to Grid entity.""" + # GIVEN a mock Grid entity with grid options + from synapseclient.models import Grid + + mock_grid = Grid(record_set_id="syn123") + mock_grid.create_async = AsyncMock(return_value=mock_grid) + grid_options = StoreGridOptions(attach_to_previous_session=True, timeout=60) + + # WHEN I call store_async with grid options + result = await store_async( + entity=mock_grid, + grid_options=grid_options, + synapse_client=MagicMock(), + ) + + # THEN create_async is called with the specified options + call_kwargs = mock_grid.create_async.call_args[1] + assert call_kwargs["attach_to_previous_session"] is True + assert call_kwargs["timeout"] == 60 + assert result is mock_grid + + +class TestStoreUnsupportedEntity: + """Tests for unsupported entity type in store_async.""" + + async def test_unsupported_entity_raises_value_error(self): + """Test that an unsupported entity type raises ValueError.""" + # GIVEN an object that is not a supported entity type + unsupported_entity = MagicMock() + # Make sure isinstance checks for all known types fail + unsupported_entity.__class__ = type("UnsupportedEntity", (), {}) + + # WHEN/THEN store_async raises ValueError + with pytest.raises(ValueError, match="Unsupported entity type"): + await store_async(entity=unsupported_entity, synapse_client=MagicMock()) + + +class TestStoreSyncWrapper: + """Tests for the synchronous store() wrapper.""" + + @patch("synapseclient.operations.store_operations.wrap_async_to_sync") + def test_store_sync_calls_wrap_async_to_sync(self, mock_wrap): + """Test that the synchronous store() calls wrap_async_to_sync.""" + from synapseclient.operations.store_operations import store + + # GIVEN a mock entity and wrap_async_to_sync + mock_entity = MagicMock() + mock_wrap.return_value = mock_entity + + # WHEN I call the sync store + result = store(entity=mock_entity, synapse_client=MagicMock()) + + # THEN wrap_async_to_sync is called + assert result is mock_entity + mock_wrap.assert_called_once() From b1fe9c143ec18f25add55c8be1f35a94dfc022b0 Mon Sep 17 00:00:00 2001 From: BryanFauble <17128019+BryanFauble@users.noreply.github.com> Date: Wed, 18 Feb 2026 19:11:21 +0000 Subject: [PATCH 6/6] Fix for test pollution --- .../synapseclient/models/async/unit_test_recordset_async.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/tests/unit/synapseclient/models/async/unit_test_recordset_async.py b/tests/unit/synapseclient/models/async/unit_test_recordset_async.py index 0a24d4eae..a01823024 100644 --- a/tests/unit/synapseclient/models/async/unit_test_recordset_async.py +++ b/tests/unit/synapseclient/models/async/unit_test_recordset_async.py @@ -61,6 +61,10 @@ class TestRecordSet: @pytest.fixture(autouse=True, scope="function") def init_syn(self, syn: Synapse) -> None: self.syn = syn + original_cache = syn.cache + yield + # Restore cache to prevent leaking MagicMock to other tests + syn.cache = original_cache def test_fill_from_dict(self) -> None: # GIVEN a RecordSet entity response