├── .editorconfig ├── .github └── workflows │ ├── publish-package.yml │ └── release.yml ├── .gitignore ├── .gitmodules ├── .luacheckrc ├── .luacov ├── .travis.yml ├── LICENSE ├── README.md ├── bin ├── run-tests.server.lua └── spec.lua ├── default.project.json ├── foreman.toml ├── lib ├── MockDataStoreService │ ├── MockDataStoreConstants.lua │ ├── MockDataStoreManager.lua │ ├── MockDataStorePages.lua │ ├── MockDataStoreUtils.lua │ ├── MockGlobalDataStore.lua │ ├── MockOrderedDataStore.lua │ └── init.lua └── init.lua ├── place.project.json ├── rotriever.toml ├── spec ├── MockDataStoreService │ ├── MockDataStoreConstants.spec.lua │ ├── MockDataStorePages.spec.lua │ ├── MockDataStoreUtils.spec.lua │ ├── MockGlobalDataStore.spec.lua │ ├── MockOrderedDataStore.spec.lua │ ├── Test.lua │ └── init.spec.lua └── init.spec.lua └── wally.toml /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | end_of_line = lf 5 | charset = utf-8 6 | trim_trailing_whitespace = true 7 | insert_final_newline = false 8 | 9 | [*.{lua,md}] 10 | indent_style = tab 11 | 12 | [*.json] 13 | indent_style = spaces 14 | indent_width = 2 -------------------------------------------------------------------------------- /.github/workflows/publish-package.yml: -------------------------------------------------------------------------------- 1 | name : Publish system packages to registry 2 | on: 3 | release: 4 | types: [published] 5 | 6 | jobs: 7 | package-publish: 8 | name: Publish packages to wally registry 9 | runs-on: ubuntu-latest 10 | steps: 11 | - name: Checkout repository 12 | uses: actions/checkout@ac593985615ec2ede58e132d2e21d2b1cbd6127c 13 | 14 | - name: Setup foreman 15 | uses: rojo-rbx/setup-foreman@62bc697705339a6049f74c9d0ff6d39cffc993e5 16 | with: 17 | token: ${{ secrets.GITHUB_TOKEN }} 18 | 19 | - name: Publish wally packages 20 | env: 21 | WALLY_AUTH: ${{ secrets.WALLY_AUTH }} 22 | run: | 23 | mkdir -p ~/.wally 24 | echo "$WALLY_AUTH" > ~/.wally/auth.toml 25 | wally publish -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name : MockDataStoreService release 2 | on: 3 | push: 4 | tags: 5 | - 'v*' 6 | workflow_dispatch: 7 | jobs: 8 | release: 9 | name: Create MockDataStoreService release 10 | runs-on: ubuntu-latest 11 | steps: 12 | - name: Checkout repository 13 | uses: actions/checkout@v2.3.0 14 | with: 15 | submodules: recursive 16 | 17 | - name: Setup foreman 18 | uses: rojo-rbx/setup-foreman@v1 19 | with: 20 | token: ${{ secrets.GITHUB_TOKEN }} 21 | 22 | - name: Build default.project.json 23 | run: | 24 | rojo build default.project.json --output MockDataStoreService.rbxmx 25 | 26 | - name: Create release 27 | id: create_release 28 | uses: actions/create-release@v1.1.0 29 | env: 30 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 31 | with: 32 | tag_name: ${{ github.ref }} 33 | release_name: ${{ github.ref }} 34 | draft: true 35 | 36 | - name: Upload MockDataStoreService.rbxmx 37 | uses: actions/upload-release-asset@v1.0.2 38 | env: 39 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 40 | with: 41 | upload_url: ${{ steps.create_release.outputs.upload_url }} 42 | asset_path: MockDataStoreService.rbxmx 43 | asset_name: MockDataStoreService.rbxmx 44 | asset_content_type: application/xml 45 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /luacov.* 2 | /*.rbxlx 3 | /*.rbxmx 4 | /*.rbxm 5 | /*.rbxl 6 | /*.lock 7 | /Packages -------------------------------------------------------------------------------- /.gitmodules: -------------------------------------------------------------------------------- 1 | [submodule "vendor/testez"] 2 | path = vendor/testez 3 | url = https://github.com/Roblox/testez 4 | -------------------------------------------------------------------------------- /.luacheckrc: -------------------------------------------------------------------------------- 1 | stds.roblox = { 2 | globals = { 3 | "game" 4 | }, 5 | 6 | read_globals = { 7 | -- Global objects 8 | "script", 9 | 10 | -- Global functions 11 | "spawn", 12 | "delay", 13 | "warn", 14 | "wait", 15 | "tick", 16 | "typeof", 17 | "settings", 18 | 19 | -- Global Namespaces 20 | "Enum", 21 | "debug", 22 | "utf8", 23 | 24 | math = { 25 | fields = { 26 | "clamp", 27 | "sign" 28 | } 29 | }, 30 | 31 | -- Global types 32 | "Instance", 33 | "Vector2", 34 | "Vector3", 35 | "CFrame", 36 | "Color3", 37 | "UDim", 38 | "UDim2", 39 | "Rect", 40 | "TweenInfo", 41 | "Random" 42 | } 43 | } 44 | 45 | stds.testez = { 46 | read_globals = { 47 | "describe", 48 | "it", "itFOCUS", "itSKIP", 49 | "FOCUS", "SKIP", "HACK_NO_XPCALL", 50 | "expect", 51 | } 52 | } 53 | 54 | ignore = { "111" } 55 | 56 | std = "lua51+roblox" 57 | 58 | files["**/*.spec.lua"] = { 59 | std = "+testez", 60 | } -------------------------------------------------------------------------------- /.luacov: -------------------------------------------------------------------------------- 1 | return { 2 | include = { 3 | "^lib", 4 | }, 5 | exclude = { 6 | "%.spec$", 7 | }, 8 | } -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | # http://kiki.to/blog/2016/02/04/talk-continuous-integration-with-lua/ 2 | language: python 3 | sudo: false 4 | 5 | branches: 6 | only: 7 | - master 8 | 9 | env: 10 | - LUA="lua=5.1" 11 | 12 | before_install: 13 | - pip install hererocks 14 | - hererocks lua_install -r^ --$LUA 15 | - export PATH=$PATH:$PWD/lua_install/bin 16 | 17 | install: 18 | - luarocks install luafilesystem 19 | - luarocks install busted 20 | - luarocks install luacov 21 | - luarocks install luacov-coveralls 22 | - luarocks install luacheck 23 | 24 | script: 25 | - luacheck lib 26 | # - lua -lluacov bin/spec.lua 27 | 28 | extra_css: 29 | - extra.css 30 | 31 | #after_success: 32 | # - luacov-coveralls -e $TRAVIS_BUILD_DIR/lua_install -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright 2018 buildthomas 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

MockDataStoreService

2 |
3 | 4 | 5 | 6 | 9 |
10 | 11 |
12 | Emulation of Roblox's DataStoreService for seamless offline development & testing 13 |
14 | 15 |
 
16 | 17 | This is a set of modules that emulates datastores in Lua rather than using the actual service. This is useful for testing in offline projects / local place files with code/frameworks that need to have access to datastores. 18 | 19 | The MockDataStoreService behaves exactly like DataStoreService: it has the same API, and will also work even if the place is not currently published to a game with Studio API access enabled (it will act as a DataStoreService with no data stored on the back-end, unless you import some data from a json string manually using the module). 20 | 21 | A small top-level helper module is provided (DataStoreService) that automatically detects and selects which datastores should be used (real datastores for published games with API access, mock datastores for offline games / published games without API access). 22 | 23 | It is recommended to use this code in the structure that it is provided in, and to simply call `require(path.to.DataStoreService)` instead of `game:GetService("DataStoreService")` anywhere in your code to use it properly. 24 | 25 | ----- 26 | 27 | **Usage:** 28 | 29 | ```lua 30 | local DataStoreService = require(the.path.to.DataStoreService) 31 | 32 | -- Use as actual DataStoreService, i.e.: 33 | 34 | local gds = DataStoreService:GetGlobalDataStore() 35 | local ds = DataStoreService:GetDataStore("TestName", "TestScope") 36 | local ods = DataStoreService:GetOrderedDataStore("TestName") 37 | 38 | local value = ds:GetAsync("TestKey") 39 | ds:SetAsync("TestKey", "TestValue") 40 | local value = ds:IncrementAsync("IntegerKey", 3) 41 | local value = ds:UpdateAsync("UpdateKey", function(oldValue) return newValue end) 42 | local value = ds:RemoveAsync("TestKey") 43 | local connection = ds:OnUpdate("UpdateKey", function(value) print(value) end) 44 | 45 | local pages = ods:GetSortedAsync(true, 50, 1, 100) 46 | repeat 47 | for _, pair in ipairs(pages:GetCurrentPage()) do 48 | local key, value = pair.key, pair.value 49 | -- (...) 50 | end 51 | until pages.IsFinished or pages:AdvanceToNextPageAsync() 52 | 53 | local budget = DataStoreService:GetRequestBudgetForRequestType( 54 | Enum.DataStoreRequestType.UpdateAsync 55 | ) 56 | 57 | -- Import/export data to a specific datastore: 58 | 59 | ds:ImportFromJSON({ -- feed table or json string representing contents of datastore 60 | TestKey = "Hello world!"; -- a key value pair 61 | AnotherKey = {a = 1, b = 2}; -- another key value pair 62 | -- (...) 63 | }) 64 | 65 | print(ds:ExportToJSON()) 66 | 67 | -- Import/export entirety of DataStoreService: 68 | 69 | DataStoreService:ImportFromJSON({ -- feed table or json string 70 | DataStore = { -- regular datastores 71 | TestName = { -- name of datastore 72 | TestScope = { -- scope of datastore 73 | TestKey = "Hello world!"; -- a key value pair 74 | AnotherKey = {1,2,3}; -- another key value pair 75 | -- (...) 76 | } 77 | } 78 | }; 79 | GlobalDataStore = { -- the (one) globaldatastore 80 | TestKey = "Hello world!"; -- a key value pair 81 | AnotherKey = {1,2,3}; -- another key value pair 82 | -- (...) 83 | }; 84 | OrderedDataStore = { -- ordered datastores 85 | TestName = { -- name of ordered datastore 86 | TestScope = { -- scope of ordered datastore 87 | TestKey = 15; -- a key value pair 88 | AnotherKey = 3; -- another key value pair 89 | -- (...) 90 | } 91 | } 92 | }; 93 | } 94 | 95 | print(DataStoreService:ExportToJSON()) 96 | 97 | ``` 98 | 99 | Review the API of datastores here: 100 | 101 | - 102 | - 103 | - 104 | - 105 | 106 | ----- 107 | 108 | **Features:** 109 | 110 | - Identical API and near-identical behavior compared to real DataStoreService. 111 | - Error messages are more verbose/accurate than the ones generated by actual datastores, which makes development/bug-fixing easier. 112 | - Throws descriptive errors for attempts at storing invalid data, telling you exactly which part of the data is invalid. (credit to @Corecii's helper function) 113 | - Emulates the yielding of datastore requests (waits a random amount of time before returning from the call). 114 | - Extra API for json-exporting/importing contents of one/all datastores for easy testing. 115 | - All operations safely deep-copy values where necessary (not possible to alter values in the datastore by externally altering tables, etc). 116 | - Enforces the "6 seconds between writes on the same key" rule. 117 | - Enforces datastore budgets correctly: budget are set and increased at the rates of the actual service, requests will be throttled if the budget is exceeded, and if there are too many throttled requests in the queue then new requests will error instead of throttling. 118 | - Functionality for simulating errors at a certain rate (similar to deprecated/removed Diabolical Mode that Studio used to have). 119 | 120 | ----- 121 | 122 | **TODOs:** 123 | 124 | - Add more test cases for budgeting 125 | - Refine existing tests 126 | -------------------------------------------------------------------------------- /bin/run-tests.server.lua: -------------------------------------------------------------------------------- 1 | -- luacheck: globals __LEMUR__ 2 | 3 | local ServerStorage = game:GetService("ServerStorage") 4 | 5 | local TestEZ = require(ServerStorage.TestEZ) 6 | 7 | local results = TestEZ.TestBootstrap:run(ServerStorage.TestDataStoreService, TestEZ.Reporters.TextReporter) 8 | 9 | if __LEMUR__ then 10 | if results.failureCount > 0 then 11 | os.exit(1) 12 | end 13 | end -------------------------------------------------------------------------------- /bin/spec.lua: -------------------------------------------------------------------------------- 1 | --[[ 2 | Loads our library and all of its dependencies, then runs tests using TestEZ. 3 | 4 | Currently unused 5 | ]] 6 | 7 | -- If you add any dependencies, add them to this table so they'll be loaded! 8 | local LOAD_MODULES = { 9 | {"lib", "DataStoreService"}, 10 | {"spec", "TestDataStoreService"}, 11 | {"vendor/testez/lib", "TestEZ"}, 12 | } 13 | 14 | -- This makes sure we can load Lemur and other libraries that depend on init.lua 15 | package.path = package.path .. ";?/init.lua" 16 | 17 | -- If this fails, make sure you've cloned all Git submodules of this repo! 18 | local lemur = require("vendor.lemur") 19 | 20 | -- Create a virtual Roblox tree 21 | local habitat = lemur.Habitat.new() 22 | 23 | -- We'll put all of our library code and dependencies here 24 | local Root = habitat.game:GetService("ServerStorage") 25 | Root.Name = "Root" 26 | 27 | -- Load all of the modules specified above 28 | for _, module in ipairs(LOAD_MODULES) do 29 | local container = habitat:loadFromFs(module[1]) 30 | container.Name = module[2] 31 | container.Parent = Root 32 | end 33 | 34 | local runTests = habitat:loadFromFs("bin/run-tests.server.lua") 35 | 36 | -- When Lemur implements a proper scheduling interface, we'll use that instead. 37 | habitat:require(runTests) -------------------------------------------------------------------------------- /default.project.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "DataStoreService", 3 | "tree": { 4 | "$path": "lib" 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /foreman.toml: -------------------------------------------------------------------------------- 1 | [tools] 2 | rojo = {source = "Roblox/rojo", version = "6.0.0-rc.1"} 3 | wally = {source = "upliftgames/wally", version = "=0.3.1"} -------------------------------------------------------------------------------- /lib/MockDataStoreService/MockDataStoreConstants.lua: -------------------------------------------------------------------------------- 1 | --[[ 2 | MockDataStoreConstants.lua 3 | Contains all constants used by the entirety of MockDataStoreService and its sub-classes. 4 | 5 | This module is licensed under APLv2, refer to the LICENSE file or: 6 | https://github.com/buildthomas/MockDataStoreService/blob/master/LICENSE 7 | ]] 8 | 9 | return { 10 | 11 | LOGGING_ENABLED = false; -- Verbose logging of transactions to output 12 | LOGGING_FUNCTION = warn; -- Function for logging messages 13 | 14 | MAX_LENGTH_KEY = 50; -- Max number of chars in key string 15 | MAX_LENGTH_NAME = 50; -- Max number of chars in name string 16 | MAX_LENGTH_SCOPE = 50; -- Max number of chars in scope string 17 | MAX_LENGTH_DATA = 4194301; -- Max number of chars in (encoded) data strings 18 | 19 | MAX_PAGE_SIZE = 100; -- Max page size for GetSortedAsync 20 | 21 | YIELD_TIME_MIN = 0.2; -- Random yield time values for set/get/update/remove/getsorted 22 | YIELD_TIME_MAX = 0.5; 23 | 24 | YIELD_TIME_UPDATE_MIN = 0.2; -- Random yield times from events from OnUpdate 25 | YIELD_TIME_UPDATE_MAX = 0.5; 26 | 27 | WRITE_COOLDOWN = 6.0; -- Amount of cooldown time between writes on the same key in a particular datastore 28 | 29 | GET_COOLDOWN = 5.0; -- Amount of cooldown time that a recent interaction with a key is considered fresh 30 | 31 | THROTTLE_QUEUE_SIZE = 30; -- Amount of requests that can be throttled at once (additional requests will error) 32 | 33 | SIMULATE_ERROR_RATE = 0; -- Rate at which requests will throw errors for testing (0 = never, 1 = always) 34 | 35 | BUDGETING_ENABLED = true; -- Whether budgets are enforced and calculated 36 | 37 | BUDGET_GETASYNC = { -- Budget constant storing structure 38 | START = 100; -- Starting budget 39 | RATE = 60; -- Added budget per minute 40 | RATE_PLR = 10; -- Additional added budget per minute per player 41 | MAX_FACTOR = 3; -- The maximum budget as a factor of (rate + rate_plr * #players) 42 | }; 43 | 44 | BUDGET_GETSORTEDASYNC = { 45 | START = 10; 46 | RATE = 5; 47 | RATE_PLR = 2; 48 | MAX_FACTOR = 3; 49 | }; 50 | 51 | BUDGET_ONUPDATE = { 52 | START = 30; 53 | RATE = 30; 54 | RATE_PLR = 5; 55 | MAX_FACTOR = 1; 56 | }; 57 | 58 | BUDGET_SETINCREMENTASYNC = { 59 | START = 100; 60 | RATE = 60; 61 | RATE_PLR = 10; 62 | MAX_FACTOR = 3; 63 | }; 64 | 65 | BUDGET_SETINCREMENTSORTEDASYNC = { 66 | START = 50; 67 | RATE = 30; 68 | RATE_PLR = 5; 69 | MAX_FACTOR = 3; 70 | }; 71 | 72 | BUDGET_BASE = 60; -- Modifiers used for budget increases on OnClose 73 | BUDGET_ONCLOSE_BASE = 150; 74 | 75 | BUDGET_UPDATE_INTERVAL = 1.0; -- Time interval in seconds at which budgets are updated (do not put too low) 76 | 77 | } 78 | -------------------------------------------------------------------------------- /lib/MockDataStoreService/MockDataStoreManager.lua: -------------------------------------------------------------------------------- 1 | --[[ 2 | MockDataStoreManager.lua 3 | This module does bookkeeping of data, interfaces and request limits used by MockDataStoreService and its sub-classes. 4 | 5 | This module is licensed under APLv2, refer to the LICENSE file or: 6 | https://github.com/buildthomas/MockDataStoreService/blob/master/LICENSE 7 | ]] 8 | 9 | local MockDataStoreManager = {} 10 | 11 | local Utils = require(script.Parent.MockDataStoreUtils) 12 | local Constants = require(script.Parent.MockDataStoreConstants) 13 | local HttpService = game:GetService("HttpService") -- for json encode/decode 14 | local Players = game:GetService("Players") -- for restoring budgets 15 | local RunService = game:GetService("RunService") -- for checking if running context is on server 16 | 17 | local ConstantsMapping = { 18 | [Enum.DataStoreRequestType.GetAsync] = Constants.BUDGET_GETASYNC; 19 | [Enum.DataStoreRequestType.GetSortedAsync] = Constants.BUDGET_GETSORTEDASYNC; 20 | [Enum.DataStoreRequestType.OnUpdate] = Constants.BUDGET_ONUPDATE; 21 | [Enum.DataStoreRequestType.SetIncrementAsync] = Constants.BUDGET_SETINCREMENTASYNC; 22 | [Enum.DataStoreRequestType.SetIncrementSortedAsync] = Constants.BUDGET_SETINCREMENTSORTEDASYNC; 23 | } 24 | 25 | -- Bookkeeping of all data: 26 | local Data = { 27 | GlobalDataStore = {}; 28 | DataStore = {}; 29 | OrderedDataStore = {}; 30 | } 31 | 32 | -- Bookkeeping of all active GlobalDataStore/OrderedDataStore interfaces indexed by data table: 33 | local Interfaces = {} 34 | 35 | -- Request limit bookkeeping: 36 | local Budgets = {} 37 | 38 | local budgetRequestQueues = { 39 | [Enum.DataStoreRequestType.GetAsync] = {}; 40 | [Enum.DataStoreRequestType.GetSortedAsync] = {}; 41 | [Enum.DataStoreRequestType.OnUpdate] = {}; 42 | [Enum.DataStoreRequestType.SetIncrementAsync] = {}; 43 | [Enum.DataStoreRequestType.SetIncrementSortedAsync] = {}; 44 | } 45 | 46 | local function initBudget() 47 | for requestType, const in pairs(ConstantsMapping) do 48 | Budgets[requestType] = const.START 49 | end 50 | Budgets[Enum.DataStoreRequestType.UpdateAsync] = math.min( 51 | Budgets[Enum.DataStoreRequestType.GetAsync], 52 | Budgets[Enum.DataStoreRequestType.SetIncrementAsync] 53 | ) 54 | end 55 | 56 | local function updateBudget(req, const, dt, n) 57 | if not Constants.BUDGETING_ENABLED then 58 | return 59 | end 60 | local rate = const.RATE + n * const.RATE_PLR 61 | Budgets[req] = math.min( 62 | Budgets[req] + dt * rate, 63 | const.MAX_FACTOR * rate 64 | ) 65 | end 66 | 67 | local function stealBudget(budget) 68 | if not Constants.BUDGETING_ENABLED then 69 | return 70 | end 71 | for _, requestType in pairs(budget) do 72 | if Budgets[requestType] then 73 | Budgets[requestType] = math.max(0, Budgets[requestType] - 1) 74 | end 75 | end 76 | Budgets[Enum.DataStoreRequestType.UpdateAsync] = math.min( 77 | Budgets[Enum.DataStoreRequestType.GetAsync], 78 | Budgets[Enum.DataStoreRequestType.SetIncrementAsync] 79 | ) 80 | end 81 | 82 | local function checkBudget(budget) 83 | if not Constants.BUDGETING_ENABLED then 84 | return true 85 | end 86 | for _, requestType in pairs(budget) do 87 | if Budgets[requestType] and Budgets[requestType] < 1 then 88 | return false 89 | end 90 | end 91 | return true 92 | end 93 | 94 | local isFrozen = false 95 | 96 | if RunService:IsServer() then 97 | -- Only do budget/throttle updating on server (in case package required on client) 98 | 99 | initBudget() 100 | 101 | task.spawn(function() -- Thread that increases budgets and de-throttles requests periodically 102 | local lastCheck = tick() 103 | while task.wait(Constants.BUDGET_UPDATE_INTERVAL) do 104 | local now = tick() 105 | local dt = (now - lastCheck) / 60 106 | lastCheck = now 107 | local n = #Players:GetPlayers() 108 | 109 | if not isFrozen then 110 | for requestType, const in pairs(ConstantsMapping) do 111 | updateBudget(requestType, const, dt, n) 112 | end 113 | Budgets[Enum.DataStoreRequestType.UpdateAsync] = math.min( 114 | Budgets[Enum.DataStoreRequestType.GetAsync], 115 | Budgets[Enum.DataStoreRequestType.SetIncrementAsync] 116 | ) 117 | end 118 | 119 | for _, budgetRequestQueue in pairs(budgetRequestQueues) do 120 | for i = #budgetRequestQueue, 1, -1 do 121 | local request = budgetRequestQueue[i] 122 | 123 | local thread = request.Thread 124 | local budget = request.Budget 125 | local key = request.Key 126 | local lock = request.Lock 127 | local cache = request.Cache 128 | 129 | if not (lock and (lock[key] or tick() - (cache[key] or 0) < Constants.WRITE_COOLDOWN)) and checkBudget(budget) then 130 | table.remove(budgetRequestQueue, i) 131 | stealBudget(budget) 132 | coroutine.resume(thread) 133 | end 134 | end 135 | end 136 | end 137 | end) 138 | 139 | game:BindToClose(function() 140 | for requestType, const in pairs(ConstantsMapping) do 141 | Budgets[requestType] = math.max( 142 | Budgets[requestType], 143 | Constants.BUDGET_ONCLOSE_BASE * (const.RATE / Constants.BUDGET_BASE) 144 | ) 145 | end 146 | Budgets[Enum.DataStoreRequestType.UpdateAsync] = math.min( 147 | Budgets[Enum.DataStoreRequestType.GetAsync], 148 | Budgets[Enum.DataStoreRequestType.SetIncrementAsync] 149 | ) 150 | end) 151 | 152 | end 153 | 154 | function MockDataStoreManager.GetGlobalData() 155 | return Data.GlobalDataStore 156 | end 157 | 158 | function MockDataStoreManager.GetData(name, scope) 159 | assert(type(name) == "string") 160 | assert(type(scope) == "string") 161 | 162 | if not Data.DataStore[name] then 163 | Data.DataStore[name] = {} 164 | end 165 | if not Data.DataStore[name][scope] then 166 | Data.DataStore[name][scope] = {} 167 | end 168 | 169 | return Data.DataStore[name][scope] 170 | end 171 | 172 | function MockDataStoreManager.GetOrderedData(name, scope) 173 | assert(type(name) == "string") 174 | assert(type(scope) == "string") 175 | 176 | if not Data.OrderedDataStore[name] then 177 | Data.OrderedDataStore[name] = {} 178 | end 179 | if not Data.OrderedDataStore[name][scope] then 180 | Data.OrderedDataStore[name][scope] = {} 181 | end 182 | 183 | return Data.OrderedDataStore[name][scope] 184 | end 185 | 186 | function MockDataStoreManager.GetDataInterface(data) 187 | return Interfaces[data] 188 | end 189 | 190 | function MockDataStoreManager.SetDataInterface(data, interface) 191 | assert(type(data) == "table") 192 | assert(type(interface) == "table") 193 | 194 | Interfaces[data] = interface 195 | end 196 | 197 | function MockDataStoreManager.GetBudget(requestType) 198 | if Constants.BUDGETING_ENABLED then 199 | return math.floor(Budgets[requestType] or 0) 200 | else 201 | return math.huge 202 | end 203 | end 204 | 205 | function MockDataStoreManager.SetBudget(requestType, budget) 206 | assert(type(budget) == "number") 207 | budget = math.max(budget, 0) 208 | 209 | if requestType == Enum.DataStoreRequestType.UpdateAsync then 210 | Budgets[Enum.DataStoreRequestType.SetIncrementAsync] = budget 211 | Budgets[Enum.DataStoreRequestType.GetAsync] = budget 212 | end 213 | 214 | if Budgets[requestType] then 215 | Budgets[requestType] = budget 216 | end 217 | end 218 | 219 | function MockDataStoreManager.ResetBudget() 220 | initBudget() 221 | end 222 | 223 | function MockDataStoreManager.FreezeBudgetUpdates() 224 | isFrozen = true 225 | end 226 | 227 | function MockDataStoreManager.ThawBudgetUpdates() 228 | isFrozen = false 229 | end 230 | 231 | function MockDataStoreManager.YieldForWriteLockAndBudget(callback, key, writeLock, writeCache, budget) 232 | assert(type(callback) == "function") 233 | assert(type(key) == "string") 234 | assert(type(writeLock) == "table") 235 | assert(type(writeCache) == "table") 236 | assert(#budget > 0) 237 | 238 | local mainRequestType = budget[1] 239 | 240 | if #budgetRequestQueues[mainRequestType] >= Constants.THROTTLE_QUEUE_SIZE then 241 | return false -- no room in throttle queue 242 | end 243 | 244 | callback() -- would i.e. trigger a warning in output 245 | 246 | table.insert(budgetRequestQueues[mainRequestType], 1, { 247 | Key = key; 248 | Lock = writeLock; 249 | Cache = writeCache; 250 | Thread = coroutine.running(); 251 | Budget = budget; 252 | }) 253 | coroutine.yield() 254 | 255 | return true 256 | end 257 | 258 | function MockDataStoreManager.YieldForBudget(callback, budget) 259 | assert(type(callback) == "function") 260 | assert(#budget > 0) 261 | 262 | local mainRequestType = budget[1] 263 | 264 | if checkBudget(budget) then 265 | stealBudget(budget) 266 | elseif #budgetRequestQueues[mainRequestType] >= Constants.THROTTLE_QUEUE_SIZE then 267 | return false -- no room in throttle queue 268 | else 269 | callback() -- would i.e. trigger a warning in output 270 | 271 | table.insert(budgetRequestQueues[mainRequestType], 1, { 272 | After = 0; -- no write lock 273 | Thread = coroutine.running(); 274 | Budget = budget; 275 | }) 276 | coroutine.yield() 277 | end 278 | 279 | return true 280 | end 281 | 282 | function MockDataStoreManager.ExportToJSON() 283 | local export = {} 284 | 285 | if next(Data.GlobalDataStore) ~= nil then -- GlobalDataStore not empty 286 | export.GlobalDataStore = Data.GlobalDataStore 287 | end 288 | export.DataStore = Utils.prepareDataStoresForExport(Data.DataStore) -- can be nil 289 | export.OrderedDataStore = Utils.prepareDataStoresForExport(Data.OrderedDataStore) -- can be nil 290 | 291 | return HttpService:JSONEncode(export) 292 | end 293 | 294 | -- Import into an entire datastore type: 295 | local function importDataStoresFromTable(origin, destination, warnFunc, methodName, prefix, isOrdered) 296 | for name, scopes in pairs(origin) do 297 | if type(name) ~= "string" then 298 | warnFunc(("%s: ignored %s > %q (name is not a string, but a %s)") 299 | :format(methodName, prefix, tostring(name), typeof(name))) 300 | elseif type(scopes) ~= "table" then 301 | warnFunc(("%s: ignored %s > %q (scope list is not a table, but a %s)") 302 | :format(methodName, prefix, name, typeof(scopes))) 303 | elseif #name == 0 then 304 | warnFunc(("%s: ignored %s > %q (name is an empty string)") 305 | :format(methodName, prefix, name)) 306 | elseif #name > Constants.MAX_LENGTH_NAME then 307 | warnFunc(("%s: ignored %s > %q (name exceeds %d character limit)") 308 | :format(methodName, prefix, name, Constants.MAX_LENGTH_NAME)) 309 | else 310 | for scope, data in pairs(scopes) do 311 | if type(scope) ~= "string" then 312 | warnFunc(("%s: ignored %s > %q > %q (scope is not a string, but a %s)") 313 | :format(methodName, prefix, name, tostring(scope), typeof(scope))) 314 | elseif type(data) ~= "table" then 315 | warnFunc(("%s: ignored %s > %q > %q (data list is not a table, but a %s)") 316 | :format(methodName, prefix, name, scope, typeof(data))) 317 | elseif #scope == 0 then 318 | warnFunc(("%s: ignored %s > %q > %q (scope is an empty string)") 319 | :format(methodName, prefix, name, scope)) 320 | elseif #scope > Constants.MAX_LENGTH_SCOPE then 321 | warnFunc(("%s: ignored %s > %q > %q (scope exceeds %d character limit)") 322 | :format(methodName, prefix, name, scope, Constants.MAX_LENGTH_SCOPE)) 323 | else 324 | if not destination[name] then 325 | destination[name] = {} 326 | end 327 | if not destination[name][scope] then 328 | destination[name][scope] = {} 329 | end 330 | Utils.importPairsFromTable( 331 | data, 332 | destination[name][scope], 333 | Interfaces[destination[name][scope]], 334 | warnFunc, 335 | methodName, 336 | ("%s > %q > %q"):format(prefix, name, scope), 337 | isOrdered 338 | ) 339 | end 340 | end 341 | end 342 | end 343 | end 344 | 345 | function MockDataStoreManager.ImportFromJSON(content, verbose) 346 | assert(type(content) == "table") 347 | assert(verbose == nil or type(verbose) == "boolean") 348 | 349 | local warnFunc = warn -- assume verbose as default 350 | if verbose == false then -- intentional formatting 351 | warnFunc = function() end 352 | end 353 | 354 | if type(content.GlobalDataStore) == "table" then 355 | Utils.importPairsFromTable( 356 | content.GlobalDataStore, 357 | Data.GlobalDataStore, 358 | Interfaces[Data.GlobalDataStore], 359 | warnFunc, 360 | "ImportFromJSON", 361 | "GlobalDataStore", 362 | false 363 | ) 364 | end 365 | if type(content.DataStore) == "table" then 366 | importDataStoresFromTable( 367 | content.DataStore, 368 | Data.DataStore, 369 | warnFunc, 370 | "ImportFromJSON", 371 | "DataStore", 372 | false 373 | ) 374 | end 375 | if type(content.OrderedDataStore) == "table" then 376 | importDataStoresFromTable( 377 | content.OrderedDataStore, 378 | Data.OrderedDataStore, 379 | warnFunc, 380 | "ImportFromJSON", 381 | "OrderedDataStore", 382 | true 383 | ) 384 | end 385 | end 386 | 387 | local function clearTable(t) 388 | for i,_ in pairs(t) do 389 | t[i] = nil 390 | end 391 | end 392 | 393 | function MockDataStoreManager.ResetData() 394 | for _, interface in pairs(Interfaces) do 395 | for key, _ in pairs(interface.__data) do 396 | interface.__data[key] = nil 397 | interface.__event:Fire(key, nil) 398 | end 399 | interface.__getCache = {} 400 | interface.__writeCache = {} 401 | interface.__writeLock = {} 402 | if interface.__sorted then 403 | interface.__sorted = {}; 404 | interface.__ref = {}; 405 | interface.__changed = false; 406 | end 407 | end 408 | 409 | clearTable(Data.GlobalDataStore) 410 | 411 | for _, scopes in pairs(Data.DataStore) do 412 | for _, data in pairs(scopes) do 413 | clearTable(data) 414 | end 415 | end 416 | 417 | for _, scopes in pairs(Data.OrderedDataStore) do 418 | for _, data in pairs(scopes) do 419 | clearTable(data) 420 | end 421 | end 422 | end 423 | 424 | return MockDataStoreManager 425 | -------------------------------------------------------------------------------- /lib/MockDataStoreService/MockDataStorePages.lua: -------------------------------------------------------------------------------- 1 | --[[ 2 | MockDataStorePages.lua 3 | This module implements the API and functionality of Roblox's DataStorePages class. 4 | 5 | This module is licensed under APLv2, refer to the LICENSE file or: 6 | https://github.com/buildthomas/MockDataStoreService/blob/master/LICENSE 7 | ]] 8 | 9 | local MockDataStorePages = {} 10 | MockDataStorePages.__index = MockDataStorePages 11 | 12 | local MockDataStoreManager = require(script.Parent.MockDataStoreManager) 13 | local Utils = require(script.Parent.MockDataStoreUtils) 14 | 15 | function MockDataStorePages:GetCurrentPage() 16 | local retValue = {} 17 | 18 | local minimumIndex = math.max(1, (self.__currentPage - 1) * self.__pageSize + 1) 19 | local maximumIndex = math.min(self.__currentPage * self.__pageSize, #self.__results) 20 | for i = minimumIndex, maximumIndex do 21 | table.insert(retValue, {key = self.__results[i].key, value = self.__results[i].value}) 22 | end 23 | 24 | return retValue 25 | end 26 | 27 | function MockDataStorePages:AdvanceToNextPageAsync() 28 | if self.IsFinished then 29 | error("AdvanceToNextPageAsync rejected with error (no pages to advance to)", 2) 30 | end 31 | 32 | Utils.simulateErrorCheck("AdvanceToNextPageAsync") 33 | 34 | local success = MockDataStoreManager.YieldForBudget( 35 | function() 36 | warn("AdvanceToNextPageAsync request was throttled due to lack of budget. Try sending fewer requests.") 37 | end, 38 | {Enum.DataStoreRequestType.GetAsync} 39 | ) 40 | 41 | if not success then 42 | error("AdvanceToNextPageAsync rejected with error (request was throttled, but throttled queue was full)", 2) 43 | end 44 | 45 | Utils.simulateYield() 46 | 47 | if #self.__results > self.__currentPage * self.__pageSize then 48 | self.__currentPage = self.__currentPage + 1 49 | end 50 | self.IsFinished = #self.__results <= self.__currentPage * self.__pageSize 51 | 52 | Utils.logMethod(self.__datastore, "AdvanceToNextPageAsync") 53 | 54 | end 55 | 56 | return MockDataStorePages 57 | -------------------------------------------------------------------------------- /lib/MockDataStoreService/MockDataStoreUtils.lua: -------------------------------------------------------------------------------- 1 | --[[ 2 | MockDataStoreUtils.lua 3 | Contains helper and utility functions used by other classes. 4 | 5 | This module is licensed under APLv2, refer to the LICENSE file or: 6 | https://github.com/buildthomas/MockDataStoreService/blob/master/LICENSE 7 | ]] 8 | 9 | local MockDataStoreUtils = {} 10 | 11 | local Constants = require(script.Parent.MockDataStoreConstants) 12 | local HttpService = game:GetService("HttpService") -- for json encode/decode 13 | local RunService = game:GetService("RunService") 14 | 15 | local rand = Random.new() 16 | 17 | local function shorten(s, num) 18 | if #s > num then 19 | return s:sub(1,num-2) .. ".." 20 | end 21 | return s 22 | end 23 | 24 | --[[ 25 | [DataStore] [Name/Scope] [GetAsync] KEY 26 | [DataStore] [Name/Scope] [UpdateAsync] KEY => VALUE 27 | [DataStore] [Name/Scope] [SetAsync] KEY => VALUE 28 | [DataStore] [Name/Scope] [IncrementAsync] KEY by INCR => VALUE 29 | [DataStore] [Name/Scope] [RemoveAsync] KEY =/> VALUE 30 | [DataStore] [Name/Scope] [OnUpdate] KEY 31 | [DataStore] [Name/Scope] [GetSortedAsync] 32 | 33 | [OrderedDataStore] [Name/Scope] [GetAsync] KEY 34 | [OrderedDataStore] [Name/Scope] [UpdateAsync] KEY => VALUE 35 | [OrderedDataStore] [Name/Scope] [SetAsync] KEY => VALUE 36 | [OrderedDataStore] [Name/Scope] [IncrementAsync] KEY + INCR => VALUE 37 | [OrderedDataStore] [Name/Scope] [RemoveAsync] KEY =/> VALUE 38 | [OrderedDataStore] [Name/Scope] [OnUpdate] KEY 39 | [OrderedDataStore] [Name/Scope] [GetSortedAsync] 40 | 41 | [OrderedDataStore] [Name/Scope] [AdvanceToNextPageAsync] 42 | ]] 43 | 44 | local function logMethod(self, method, key, value, increment) 45 | if not Constants.LOGGING_ENABLED or type(Constants.LOGGING_FUNCTION) ~= "function" then 46 | return 47 | end 48 | 49 | local name = self.__name 50 | local scope = self.__scope 51 | 52 | local prefix 53 | if not name then 54 | prefix = ("[GlobalDataStore] [%s]"):format(method) 55 | elseif not scope then 56 | prefix = ("[%s] [%s] [%s]"):format(self.__type, shorten(name, 20), method) 57 | else 58 | prefix = ("[%s] [%s/%s] [%s]"):format(self.__type, shorten(name, 15), shorten(scope, 15), method) 59 | end 60 | 61 | local message 62 | if value and increment then 63 | message = key .. " + " .. tostring(increment) .. " => " .. tostring(value) 64 | elseif increment then 65 | message = key .. " + " .. tostring(increment) 66 | elseif value then 67 | if method == "RemoveAsync" then 68 | message = key .. " =/> " .. tostring(value) 69 | else 70 | message = key .. " => " .. tostring(value) 71 | end 72 | else 73 | message = "key" 74 | end 75 | 76 | Constants.LOGGING_FUNCTION(prefix .. " " .. message) 77 | 78 | end 79 | 80 | local function deepcopy(t) 81 | if type(t) == "table" then 82 | local n = {} 83 | for i,v in pairs(t) do 84 | n[i] = deepcopy(v) 85 | end 86 | return n 87 | else 88 | return t 89 | end 90 | end 91 | 92 | local function scanValidity(tbl, passed, path) -- Credit to Corecii (edited) 93 | if type(tbl) ~= "table" then 94 | return scanValidity({input = tbl}, {}, {}) 95 | end 96 | passed, path = passed or {}, path or {"root"} 97 | passed[tbl] = true 98 | local tblType 99 | do 100 | local key = next(tbl) 101 | if type(key) == "number" then 102 | tblType = "Array" 103 | else 104 | tblType = "Dictionary" 105 | end 106 | end 107 | local last = 0 108 | for key, value in next, tbl do 109 | path[#path + 1] = tostring(key) 110 | if type(key) == "number" then 111 | if tblType == "Dictionary" then 112 | return false, path, "cannot store mixed tables" 113 | elseif key % 1 ~= 0 then 114 | return false, path, "cannot store tables with non-integer indices" 115 | elseif key == math.huge or key == -math.huge then 116 | return false, path, "cannot store tables with (-)infinity indices" 117 | end 118 | elseif type(key) ~= "string" then 119 | return false, path, "dictionaries cannot have keys of type " .. typeof(key) 120 | elseif tblType == "Array" then 121 | return false, path, "cannot store mixed tables" 122 | elseif not utf8.len(key) then 123 | return false, path, "dictionary has key that is invalid UTF-8" 124 | end 125 | if tblType == "Array" then 126 | if last ~= key - 1 then 127 | return false, path, "array has non-sequential indices" 128 | end 129 | last = key 130 | end 131 | if type(value) == "userdata" or type(value) == "function" or type(value) == "thread" then 132 | return false, path, "cannot store value '" .. tostring(value) .. "' of type " .. typeof(value) 133 | elseif type(value) == "string" and not utf8.len(value) then 134 | return false, path, "cannot store strings that are invalid UTF-8" 135 | end 136 | if type(value) == "table" then 137 | if passed[value] then 138 | return false, path, "cannot store cyclic tables" 139 | end 140 | local isValid, keyPath, reason = scanValidity(value, passed, path) 141 | if not isValid then 142 | return isValid, keyPath, reason 143 | end 144 | end 145 | path[#path] = nil 146 | end 147 | passed[tbl] = nil 148 | return true 149 | end 150 | 151 | local function getStringPath(path) 152 | return table.concat(path, '.') 153 | end 154 | 155 | -- Import into a single datastore: 156 | local function importPairsFromTable(origin, destination, interface, warnFunc, methodName, prefix, isOrdered) 157 | for key, value in pairs(origin) do 158 | if type(key) ~= "string" then 159 | warnFunc(("%s: ignored %s > '%s' (key is not a string, but a %s)") 160 | :format(methodName, prefix, tostring(key), typeof(key))) 161 | elseif not utf8.len(key) then 162 | warnFunc(("%s: ignored %s > '%s' (key is not valid UTF-8)") 163 | :format(methodName, prefix, tostring(key))) 164 | elseif #key > Constants.MAX_LENGTH_KEY then 165 | warnFunc(("%s: ignored %s > '%s' (key exceeds %d character limit)") 166 | :format(methodName, prefix, key, Constants.MAX_LENGTH_KEY)) 167 | elseif type(value) == "string" and #value > Constants.MAX_LENGTH_DATA then 168 | warnFunc(("%s: ignored %s > '%s' (length of value exceeds %d character limit)") 169 | :format(methodName, prefix, key, Constants.MAX_LENGTH_DATA)) 170 | elseif type(value) == "table" and #HttpService:JSONEncode(value) > Constants.MAX_LENGTH_DATA then 171 | warnFunc(("%s: ignored %s > '%s' (length of encoded value exceeds %d character limit)") 172 | :format(methodName, prefix, key, Constants.MAX_LENGTH_DATA)) 173 | elseif type(value) == "function" or type(value) == "userdata" or type(value) == "thread" then 174 | warnFunc(("%s: ignored %s > '%s' (cannot store value '%s' of type %s)") 175 | :format(methodName, prefix, key, tostring(value), type(value))) 176 | elseif isOrdered and type(value) ~= "number" then 177 | warnFunc(("%s: ignored %s > '%s' (cannot store value '%s' of type %s in OrderedDataStore)") 178 | :format(methodName, prefix, key, tostring(value), type(value))) 179 | elseif isOrdered and value % 1 ~= 0 then 180 | warnFunc(("%s: ignored %s > '%s' (cannot store non-integer value '%s' in OrderedDataStore)") 181 | :format(methodName, prefix, key, tostring(value))) 182 | elseif type(value) == "string" and not utf8.len(value) then 183 | warnFunc(("%s: ignored %s > '%s' (string value is not valid UTF-8)") 184 | :format(methodName, prefix, key, tostring(value), type(value))) 185 | else 186 | local isValid = true 187 | local keyPath, reason 188 | if type(value) == "table" then 189 | isValid, keyPath, reason = scanValidity(value) 190 | end 191 | if isOrdered then 192 | value = math.floor(value + .5) 193 | end 194 | if isValid then 195 | local old = destination[key] 196 | destination[key] = value 197 | if interface and old ~= value then -- hacky block to fire OnUpdate signals 198 | if isOrdered and interface then -- hacky block to populate internal structures for OrderedDataStores 199 | if interface.__ref[key] then 200 | interface.__ref[key].Value = value 201 | interface.__changed = true 202 | else 203 | interface.__ref[key] = {Key = key, Value = interface.__data[key]} 204 | table.insert(interface.__sorted, interface.__ref[key]) 205 | interface.__changed = true 206 | end 207 | end 208 | interface.__event:Fire(key, value) 209 | end 210 | else 211 | warnFunc(("%s: ignored %s > '%s' (table has invalid entry at <%s>: %s)") 212 | :format(methodName, prefix, key, getStringPath(keyPath), reason)) 213 | end 214 | end 215 | end 216 | end 217 | 218 | -- Trim empty datastores and scopes from an entire datastore type: 219 | local function prepareDataStoresForExport(origin) 220 | local dataPrepared = {} 221 | 222 | for name, scopes in pairs(origin) do 223 | local exportScopes = {} 224 | for scope, data in pairs(scopes) do 225 | local exportData = {} 226 | for key, value in pairs(data) do 227 | exportData[key] = value 228 | end 229 | if next(exportData) ~= nil then -- Only export datastore when non-empty 230 | exportScopes[scope] = exportData 231 | end 232 | end 233 | if next(exportScopes) ~= nil then -- Only export scope list when non-empty 234 | dataPrepared[name] = exportScopes 235 | end 236 | end 237 | 238 | if next(dataPrepared) ~= nil then -- Only return datastore type when non-empty 239 | return dataPrepared 240 | end 241 | end 242 | 243 | local function preprocessKey(key) 244 | if type(key) == "number" then 245 | if key ~= key then 246 | return "NAN" 247 | elseif key >= math.huge then 248 | return "INF" 249 | elseif key <= -math.huge then 250 | return "-INF" 251 | end 252 | return tostring(key) 253 | end 254 | return key 255 | end 256 | 257 | local function simulateYield() 258 | if Constants.YIELD_TIME_MAX > 0 then 259 | task.wait(rand:NextNumber(Constants.YIELD_TIME_MIN, Constants.YIELD_TIME_MAX)) 260 | end 261 | end 262 | 263 | local function simulateErrorCheck(method) 264 | if Constants.SIMULATE_ERROR_RATE > 0 and rand:NextNumber() <= Constants.SIMULATE_ERROR_RATE then 265 | simulateYield() 266 | error(method .. " rejected with error (simulated error)", 3) 267 | end 268 | end 269 | 270 | -- Setting these here so the functions above can self-reference just by name: 271 | MockDataStoreUtils.logMethod = logMethod 272 | MockDataStoreUtils.deepcopy = deepcopy 273 | MockDataStoreUtils.scanValidity = scanValidity 274 | MockDataStoreUtils.getStringPath = getStringPath 275 | MockDataStoreUtils.importPairsFromTable = importPairsFromTable 276 | MockDataStoreUtils.prepareDataStoresForExport = prepareDataStoresForExport 277 | MockDataStoreUtils.preprocessKey = preprocessKey 278 | MockDataStoreUtils.simulateYield = simulateYield 279 | MockDataStoreUtils.simulateErrorCheck = simulateErrorCheck 280 | 281 | return MockDataStoreUtils 282 | -------------------------------------------------------------------------------- /lib/MockDataStoreService/MockGlobalDataStore.lua: -------------------------------------------------------------------------------- 1 | --[[ 2 | MockGlobalDataStore.lua 3 | This module implements the API and functionality of Roblox's GlobalDataStore class. 4 | 5 | This module is licensed under APLv2, refer to the LICENSE file or: 6 | https://github.com/buildthomas/MockDataStoreService/blob/master/LICENSE 7 | ]] 8 | 9 | local MockGlobalDataStore = {} 10 | MockGlobalDataStore.__index = MockGlobalDataStore 11 | 12 | local MockDataStoreManager = require(script.Parent.MockDataStoreManager) 13 | local Utils = require(script.Parent.MockDataStoreUtils) 14 | local Constants = require(script.Parent.MockDataStoreConstants) 15 | local HttpService = game:GetService("HttpService") -- for json encode/decode 16 | 17 | local rand = Random.new() 18 | 19 | function MockGlobalDataStore:OnUpdate(key, callback) 20 | key = Utils.preprocessKey(key) 21 | if type(key) ~= "string" then 22 | error(("bad argument #1 to 'OnUpdate' (string expected, got %s)"):format(typeof(key)), 2) 23 | elseif type(callback) ~= "function" then 24 | error(("bad argument #2 to 'OnUpdate' (function expected, got %s)"):format(typeof(callback)), 2) 25 | elseif #key == 0 then 26 | error("bad argument #1 to 'OnUpdate' (key name can't be empty)", 2) 27 | elseif #key > Constants.MAX_LENGTH_KEY then 28 | error(("bad argument #1 to 'OnUpdate' (key name exceeds %d character limit)"):format(Constants.MAX_LENGTH_KEY), 2) 29 | end 30 | 31 | Utils.simulateErrorCheck("OnUpdate") 32 | 33 | local success = MockDataStoreManager.YieldForBudget( 34 | function() 35 | warn(("OnUpdate request was throttled due to lack of budget. Try sending fewer requests. Key = %s"):format(key)) 36 | end, 37 | {Enum.DataStoreRequestType.OnUpdate} 38 | ) 39 | 40 | if not success then 41 | error("OnUpdate rejected with error (request was throttled, but throttled queue was full)", 2) 42 | end 43 | 44 | Utils.logMethod(self, "OnUpdate", key) 45 | 46 | return self.__event.Event:Connect(function(k, v) 47 | if k == key then 48 | if Constants.YIELD_TIME_UPDATE_MAX > 0 then 49 | task.wait(rand:NextNumber(Constants.YIELD_TIME_UPDATE_MIN, Constants.YIELD_TIME_UPDATE_MAX)) 50 | end 51 | callback(v) -- v was implicitly deep-copied 52 | end 53 | end) 54 | end 55 | 56 | function MockGlobalDataStore:GetAsync(key) 57 | key = Utils.preprocessKey(key) 58 | if type(key) ~= "string" then 59 | error(("bad argument #1 to 'GetAsync' (string expected, got %s)"):format(typeof(key)), 2) 60 | elseif #key == 0 then 61 | error("bad argument #1 to 'GetAsync' (key name can't be empty)", 2) 62 | elseif #key > Constants.MAX_LENGTH_KEY then 63 | error(("bad argument #1 to 'GetAsync' (key name exceeds %d character limit)"):format(Constants.MAX_LENGTH_KEY), 2) 64 | end 65 | 66 | if self.__getCache[key] and tick() - self.__getCache[key] < Constants.GET_COOLDOWN then 67 | return Utils.deepcopy(self.__data[key]) 68 | end 69 | 70 | Utils.simulateErrorCheck("GetAsync") 71 | 72 | local success = MockDataStoreManager.YieldForBudget( 73 | function() 74 | warn(("GetAsync request was throttled due to lack of budget. Try sending fewer requests. Key = %s"):format(key)) 75 | end, 76 | {Enum.DataStoreRequestType.GetAsync} 77 | ) 78 | 79 | if not success then 80 | error("GetAsync rejected with error (request was throttled, but throttled queue was full)", 2) 81 | end 82 | 83 | self.__getCache[key] = tick() 84 | 85 | local retValue = Utils.deepcopy(self.__data[key]) 86 | 87 | Utils.simulateYield() 88 | 89 | Utils.logMethod(self, "GetAsync", key) 90 | 91 | return retValue 92 | end 93 | 94 | function MockGlobalDataStore:IncrementAsync(key, delta) 95 | key = Utils.preprocessKey(key) 96 | if type(key) ~= "string" then 97 | error(("bad argument #1 to 'IncrementAsync' (string expected, got %s)"):format(typeof(key)), 2) 98 | elseif delta ~= nil and type(delta) ~= "number" then 99 | error(("bad argument #2 to 'IncrementAsync' (number expected, got %s)"):format(typeof(delta)), 2) 100 | elseif #key == 0 then 101 | error("bad argument #1 to 'IncrementAsync' (key name can't be empty)", 2) 102 | elseif #key > Constants.MAX_LENGTH_KEY then 103 | error(("bad argument #1 to 'IncrementAsync' (key name exceeds %d character limit)") 104 | :format(Constants.MAX_LENGTH_KEY), 2) 105 | end 106 | 107 | Utils.simulateErrorCheck("IncrementAsync") 108 | 109 | local success 110 | 111 | if self.__writeLock[key] or tick() - (self.__writeCache[key] or 0) < Constants.WRITE_COOLDOWN then 112 | success = MockDataStoreManager.YieldForWriteLockAndBudget( 113 | function() 114 | warn(("IncrementAsync request was throttled, a key can only be written to once every %d seconds. Key = %s") 115 | :format(Constants.WRITE_COOLDOWN, key)) 116 | end, 117 | key, 118 | self.__writeLock, 119 | self.__writeCache, 120 | {Enum.DataStoreRequestType.SetIncrementAsync} 121 | ) 122 | else 123 | self.__writeLock[key] = true 124 | success = MockDataStoreManager.YieldForBudget( 125 | function() 126 | warn(("IncrementAsync request was throttled due to lack of budget. Try sending fewer requests. Key = %s") 127 | :format(key)) 128 | end, 129 | {Enum.DataStoreRequestType.SetIncrementAsync} 130 | ) 131 | self.__writeLock[key] = nil 132 | end 133 | 134 | if not success then 135 | error("IncrementAsync rejected with error (request was throttled, but throttled queue was full)", 2) 136 | end 137 | 138 | local old = self.__data[key] 139 | 140 | if old ~= nil and (type(old) ~= "number" or old % 1 ~= 0) then 141 | Utils.simulateYield() 142 | error("IncrementAsync rejected with error (cannot increment non-integer value)", 2) 143 | end 144 | 145 | self.__writeLock[key] = true 146 | 147 | delta = delta and math.floor(delta + .5) or 1 148 | 149 | self.__data[key] = (old or 0) + delta 150 | 151 | if old == nil or delta ~= 0 then 152 | self.__event:Fire(key, self.__data[key]) 153 | end 154 | 155 | local retValue = self.__data[key] 156 | 157 | Utils.simulateYield() 158 | 159 | self.__writeLock[key] = nil 160 | self.__writeCache[key] = tick() 161 | 162 | self.__getCache[key] = tick() 163 | 164 | Utils.logMethod(self, "IncrementAsync", key, retValue, delta) 165 | 166 | return retValue 167 | end 168 | 169 | function MockGlobalDataStore:RemoveAsync(key) 170 | key = Utils.preprocessKey(key) 171 | if type(key) ~= "string" then 172 | error(("bad argument #1 to 'RemoveAsync' (string expected, got %s)"):format(typeof(key)), 2) 173 | elseif #key == 0 then 174 | error("bad argument #1 to 'RemoveAsync' (key name can't be empty)", 2) 175 | elseif #key > Constants.MAX_LENGTH_KEY then 176 | error(("bad argument #1 to 'RemoveAsync' (key name exceeds %d character limit)"):format(Constants.MAX_LENGTH_KEY), 2) 177 | end 178 | 179 | Utils.simulateErrorCheck("RemoveAsync") 180 | 181 | local success 182 | 183 | if self.__writeLock[key] or tick() - (self.__writeCache[key] or 0) < Constants.WRITE_COOLDOWN then 184 | success = MockDataStoreManager.YieldForWriteLockAndBudget( 185 | function() 186 | warn(("RemoveAsync request was throttled, a key can only be written to once every %d seconds. Key = %s") 187 | :format(Constants.WRITE_COOLDOWN, key)) 188 | end, 189 | key, 190 | self.__writeLock, 191 | self.__writeCache, 192 | {Enum.DataStoreRequestType.SetIncrementAsync} 193 | ) 194 | else 195 | self.__writeLock[key] = true 196 | success = MockDataStoreManager.YieldForBudget( 197 | function() 198 | warn(("RemoveAsync request was throttled due to lack of budget. Try sending fewer requests. Key = %s") 199 | :format(key)) 200 | end, 201 | {Enum.DataStoreRequestType.SetIncrementAsync} 202 | ) 203 | self.__writeLock[key] = nil 204 | end 205 | 206 | if not success then 207 | error("RemoveAsync rejected with error (request was throttled, but throttled queue was full)", 2) 208 | end 209 | 210 | self.__writeLock[key] = true 211 | 212 | local value = Utils.deepcopy(self.__data[key]) 213 | self.__data[key] = nil 214 | 215 | if value ~= nil then 216 | self.__event:Fire(key, nil) 217 | end 218 | 219 | Utils.simulateYield() 220 | 221 | self.__writeLock[key] = nil 222 | self.__writeCache[key] = tick() 223 | 224 | Utils.logMethod(self, "RemoveAsync", key, value) 225 | 226 | return value 227 | end 228 | 229 | function MockGlobalDataStore:SetAsync(key, value) 230 | key = Utils.preprocessKey(key) 231 | if type(key) ~= "string" then 232 | error(("bad argument #1 to 'SetAsync' (string expected, got %s)"):format(typeof(key)), 2) 233 | elseif #key == 0 then 234 | error("bad argument #1 to 'SetAsync' (key name can't be empty)", 2) 235 | elseif #key > Constants.MAX_LENGTH_KEY then 236 | error(("bad argument #1 to 'SetAsync' (key name exceeds %d character limit)"):format(Constants.MAX_LENGTH_KEY), 2) 237 | elseif value == nil or type(value) == "function" or type(value) == "userdata" or type(value) == "thread" then 238 | error(("bad argument #2 to 'SetAsync' (cannot store value '%s' of type %s)") 239 | :format(tostring(value), typeof(value)), 2) 240 | end 241 | 242 | if type(value) == "table" then 243 | local isValid, keyPath, reason = Utils.scanValidity(value) 244 | if not isValid then 245 | error(("bad argument #2 to 'SetAsync' (table has invalid entry at <%s>: %s)") 246 | :format(Utils.getStringPath(keyPath), reason), 2) 247 | end 248 | local pass, content = pcall(function() return HttpService:JSONEncode(value) end) 249 | if not pass then 250 | error("bad argument #2 to 'SetAsync' (table could not be encoded to json)", 2) 251 | elseif #content > Constants.MAX_LENGTH_DATA then 252 | error(("bad argument #2 to 'SetAsync' (encoded data length exceeds %d character limit)") 253 | :format(Constants.MAX_LENGTH_DATA), 2) 254 | end 255 | elseif type(value) == "string" then 256 | if #value > Constants.MAX_LENGTH_DATA then 257 | error(("bad argument #2 to 'SetAsync' (data length exceeds %d character limit)") 258 | :format(Constants.MAX_LENGTH_DATA), 2) 259 | elseif not utf8.len(value) then 260 | error("bad argument #2 to 'SetAsync' (string value is not valid UTF-8)", 2) 261 | end 262 | end 263 | 264 | Utils.simulateErrorCheck("SetAsync") 265 | 266 | local success 267 | 268 | if self.__writeLock[key] or tick() - (self.__writeCache[key] or 0) < Constants.WRITE_COOLDOWN then 269 | success = MockDataStoreManager.YieldForWriteLockAndBudget( 270 | function() 271 | warn(("SetAsync request was throttled, a key can only be written to once every %d seconds. Key = %s") 272 | :format(Constants.WRITE_COOLDOWN, key)) 273 | end, 274 | key, 275 | self.__writeLock, 276 | self.__writeCache, 277 | {Enum.DataStoreRequestType.SetIncrementAsync} 278 | ) 279 | else 280 | self.__writeLock[key] = true 281 | success = MockDataStoreManager.YieldForBudget( 282 | function() 283 | warn(("SetAsync request was throttled due to lack of budget. Try sending fewer requests. Key = %s") 284 | :format(key)) 285 | end, 286 | {Enum.DataStoreRequestType.SetIncrementAsync} 287 | ) 288 | self.__writeLock[key] = nil 289 | end 290 | 291 | if not success then 292 | error("SetAsync rejected with error (request was throttled, but throttled queue was full)", 2) 293 | end 294 | 295 | self.__writeLock[key] = true 296 | 297 | if type(value) == "table" or value ~= self.__data[key] then 298 | self.__data[key] = Utils.deepcopy(value) 299 | self.__event:Fire(key, self.__data[key]) 300 | end 301 | 302 | Utils.simulateYield() 303 | 304 | self.__writeLock[key] = nil 305 | self.__writeCache[key] = tick() 306 | 307 | Utils.logMethod(self, "SetAsync", key, self.__data[key]) 308 | 309 | end 310 | 311 | function MockGlobalDataStore:UpdateAsync(key, transformFunction) 312 | key = Utils.preprocessKey(key) 313 | if type(key) ~= "string" then 314 | error(("bad argument #1 to 'UpdateAsync' (string expected, got %s)"):format(typeof(key)), 2) 315 | elseif type(transformFunction) ~= "function" then 316 | error(("bad argument #2 to 'UpdateAsync' (function expected, got %s)"):format(typeof(transformFunction)), 2) 317 | elseif #key == 0 then 318 | error("bad argument #1 to 'UpdateAsync' (key name can't be empty)", 2) 319 | elseif #key > Constants.MAX_LENGTH_KEY then 320 | error(("bad argument #1 to 'UpdateAsync' (key name exceeds %d character limit)"):format(Constants.MAX_LENGTH_KEY), 2) 321 | end 322 | 323 | Utils.simulateErrorCheck("UpdateAsync") 324 | 325 | local success 326 | 327 | if self.__writeLock[key] or tick() - (self.__writeCache[key] or 0) < Constants.WRITE_COOLDOWN then 328 | success = MockDataStoreManager.YieldForWriteLockAndBudget( 329 | function() 330 | warn(("UpdateAsync request was throttled, a key can only be written to once every %d seconds. Key = %s") 331 | :format(Constants.WRITE_COOLDOWN, key)) 332 | end, 333 | key, 334 | self.__writeLock, 335 | self.__writeCache, 336 | {Enum.DataStoreRequestType.SetIncrementAsync} 337 | ) 338 | else 339 | self.__writeLock[key] = true 340 | local budget 341 | if self.__getCache[key] and tick() - self.__getCache[key] < Constants.GET_COOLDOWN then 342 | budget = {Enum.DataStoreRequestType.SetIncrementAsync} 343 | else 344 | budget = {Enum.DataStoreRequestType.GetAsync, Enum.DataStoreRequestType.SetIncrementAsync} 345 | end 346 | success = MockDataStoreManager.YieldForBudget( 347 | function() 348 | warn(("UpdateAsync request was throttled due to lack of budget. Try sending fewer requests. Key = %s") 349 | :format(key)) 350 | end, 351 | budget 352 | ) 353 | self.__writeLock[key] = nil 354 | end 355 | 356 | if not success then 357 | error("UpdateAsync rejected with error (request was throttled, but throttled queue was full)", 2) 358 | end 359 | 360 | local value = transformFunction(Utils.deepcopy(self.__data[key])) 361 | 362 | if value == nil then -- cancel update after remote call 363 | Utils.simulateYield() 364 | return nil -- this is what datastores do even though it should be old value 365 | end 366 | 367 | if type(value) == "function" or type(value) == "userdata" or type(value) == "thread" then 368 | error(("UpdateAsync rejected with error (resulting value '%s' is of type %s that cannot be stored)") 369 | :format(tostring(value), typeof(value)), 2) 370 | end 371 | 372 | if type(value) == "table" then 373 | local isValid, keyPath, reason = Utils.scanValidity(value) 374 | if not isValid then 375 | error(("UpdateAsync rejected with error (resulting table has invalid entry at <%s>: %s)") 376 | :format(Utils.getStringPath(keyPath), reason), 2) 377 | end 378 | local pass, content = pcall(function() return HttpService:JSONEncode(value) end) 379 | if not pass then 380 | error("UpdateAsync rejected with error (resulting table could not be encoded to json)", 2) 381 | elseif #content > Constants.MAX_LENGTH_DATA then 382 | error(("UpdateAsync rejected with error (resulting encoded data length exceeds %d character limit)") 383 | :format(Constants.MAX_LENGTH_DATA), 2) 384 | end 385 | elseif type(value) == "string" then 386 | if #value > Constants.MAX_LENGTH_DATA then 387 | error(("UpdateAsync rejected with error (resulting data length exceeds %d character limit)") 388 | :format(Constants.MAX_LENGTH_DATA), 2) 389 | elseif not utf8.len(value) then 390 | error("UpdateAsync rejected with error (string value is not valid UTF-8)", 2) 391 | end 392 | end 393 | 394 | self.__writeLock[key] = true 395 | 396 | if type(value) == "table" or value ~= self.__data[key] then 397 | self.__data[key] = Utils.deepcopy(value) 398 | self.__event:Fire(key, self.__data[key]) 399 | end 400 | 401 | local retValue = Utils.deepcopy(value) 402 | 403 | self.__writeLock[key] = nil 404 | self.__writeCache[key] = tick() 405 | 406 | self.__getCache[key] = tick() 407 | 408 | Utils.logMethod(self, "UpdateAsync", key, retValue) 409 | 410 | return retValue 411 | end 412 | 413 | function MockGlobalDataStore:ExportToJSON() 414 | return HttpService:JSONEncode(self.__data) 415 | end 416 | 417 | function MockGlobalDataStore:ImportFromJSON(json, verbose) 418 | local content 419 | if type(json) == "string" then 420 | local parsed, value = pcall(function() return HttpService:JSONDecode(json) end) 421 | if not parsed then 422 | error("bad argument #1 to 'ImportFromJSON' (string is not valid json)", 2) 423 | end 424 | content = value 425 | elseif type(json) == "table" then 426 | content = Utils.deepcopy(json) 427 | else 428 | error(("bad argument #1 to 'ImportFromJSON' (string or table expected, got %s)"):format(typeof(json)), 2) 429 | end 430 | 431 | if verbose ~= nil and type(verbose) ~= "boolean" then 432 | error(("bad argument #2 to 'ImportFromJSON' (boolean expected, got %s)"):format(typeof(verbose)), 2) 433 | end 434 | 435 | Utils.importPairsFromTable( 436 | content, 437 | self.__data, 438 | MockDataStoreManager.GetDataInterface(self.__data), 439 | (verbose == false and function() end or warn), 440 | "ImportFromJSON", 441 | ((type(self.__name) == "string" and type(self.__scope) == "string") 442 | and ("DataStore > %s > %s"):format(self.__name, self.__scope) 443 | or "GlobalDataStore"), 444 | false 445 | ) 446 | end 447 | 448 | return MockGlobalDataStore 449 | -------------------------------------------------------------------------------- /lib/MockDataStoreService/MockOrderedDataStore.lua: -------------------------------------------------------------------------------- 1 | --[[ 2 | MockOrderedDataStore.lua 3 | This module implements the API and functionality of Roblox's OrderedDataStore class. 4 | 5 | This module is licensed under APLv2, refer to the LICENSE file or: 6 | https://github.com/buildthomas/MockDataStoreService/blob/master/LICENSE 7 | ]] 8 | 9 | local MockOrderedDataStore = {} 10 | MockOrderedDataStore.__index = MockOrderedDataStore 11 | 12 | local MockDataStoreManager = require(script.Parent.MockDataStoreManager) 13 | local MockDataStorePages = require(script.Parent.MockDataStorePages) 14 | local Utils = require(script.Parent.MockDataStoreUtils) 15 | local Constants = require(script.Parent.MockDataStoreConstants) 16 | local HttpService = game:GetService("HttpService") -- for json encode/decode 17 | 18 | local rand = Random.new() 19 | 20 | function MockOrderedDataStore:OnUpdate(key, callback) 21 | key = Utils.preprocessKey(key) 22 | if type(key) ~= "string" then 23 | error(("bad argument #1 to 'OnUpdate' (string expected, got %s)"):format(typeof(key)), 2) 24 | elseif type(callback) ~= "function" then 25 | error(("bad argument #2 to 'OnUpdate' (function expected, got %s)"):format(typeof(callback)), 2) 26 | elseif #key == 0 then 27 | error("bad argument #1 to 'OnUpdate' (key name can't be empty)", 2) 28 | elseif #key > Constants.MAX_LENGTH_KEY then 29 | error(("bad argument #1 to 'OnUpdate' (key name exceeds %d character limit)"):format(Constants.MAX_LENGTH_KEY), 2) 30 | end 31 | 32 | Utils.simulateErrorCheck("OnUpdate") 33 | 34 | local success = MockDataStoreManager.YieldForBudget( 35 | function() 36 | warn(("OnUpdate request was throttled due to lack of budget. Try sending fewer requests. Key = %s"):format(key)) 37 | end, 38 | {Enum.DataStoreRequestType.OnUpdate} 39 | ) 40 | 41 | if not success then 42 | error("OnUpdate rejected with error (request was throttled, but throttled queue was full)", 2) 43 | end 44 | 45 | Utils.logMethod(self, "OnUpdate", key) 46 | 47 | return self.__event.Event:Connect(function(k, v) 48 | if k == key then 49 | if Constants.YIELD_TIME_UPDATE_MAX > 0 then 50 | task.wait(rand:NextNumber(Constants.YIELD_TIME_UPDATE_MIN, Constants.YIELD_TIME_UPDATE_MAX)) 51 | end 52 | callback(v) -- v was implicitly deep-copied 53 | end 54 | end) 55 | end 56 | 57 | function MockOrderedDataStore:GetAsync(key) 58 | key = Utils.preprocessKey(key) 59 | if type(key) ~= "string" then 60 | error(("bad argument #1 to 'GetAsync' (string expected, got %s)"):format(typeof(key)), 2) 61 | elseif #key == 0 then 62 | error("bad argument #1 to 'GetAsync' (key name can't be empty)", 2) 63 | elseif #key > Constants.MAX_LENGTH_KEY then 64 | error(("bad argument #1 to 'GetAsync' (key name exceeds %d character limit)"):format(Constants.MAX_LENGTH_KEY), 2) 65 | end 66 | 67 | if self.__getCache[key] and tick() - self.__getCache[key] < Constants.GET_COOLDOWN then 68 | return self.__data[key] 69 | end 70 | 71 | Utils.simulateErrorCheck("GetAsync") 72 | 73 | local success = MockDataStoreManager.YieldForBudget( 74 | function() 75 | warn(("GetAsync request was throttled due to lack of budget. Try sending fewer requests. Key = %s"):format(key)) 76 | end, 77 | {Enum.DataStoreRequestType.GetAsync} 78 | ) 79 | 80 | if not success then 81 | error("GetAsync rejected with error (request was throttled, but throttled queue was full)", 2) 82 | end 83 | 84 | local retValue = self.__data[key] 85 | 86 | Utils.simulateYield() 87 | 88 | self.__getCache[key] = tick() 89 | 90 | Utils.logMethod(self, "GetAsync", key) 91 | 92 | return retValue 93 | end 94 | 95 | function MockOrderedDataStore:IncrementAsync(key, delta) 96 | key = Utils.preprocessKey(key) 97 | if type(key) ~= "string" then 98 | error(("bad argument #1 to 'IncrementAsync' (string expected, got %s)"):format(typeof(key)), 2) 99 | elseif delta ~= nil and type(delta) ~= "number" then 100 | error(("bad argument #2 to 'IncrementAsync' (number expected, got %s)"):format(typeof(delta)), 2) 101 | elseif #key == 0 then 102 | error("bad argument #1 to 'IncrementAsync' (key name can't be empty)", 2) 103 | elseif #key > Constants.MAX_LENGTH_KEY then 104 | error(("bad argument #1 to 'IncrementAsync' (key name exceeds %d character limit)") 105 | :format(Constants.MAX_LENGTH_KEY), 2) 106 | end 107 | 108 | Utils.simulateErrorCheck("IncrementAsync") 109 | 110 | local success 111 | 112 | if self.__writeLock[key] or tick() - (self.__writeCache[key] or 0) < Constants.WRITE_COOLDOWN then 113 | success = MockDataStoreManager.YieldForWriteLockAndBudget( 114 | function() 115 | warn(("IncrementAsync request was throttled, a key can only be written to once every %d seconds. Key = %s") 116 | :format(Constants.WRITE_COOLDOWN, key)) 117 | end, 118 | key, 119 | self.__writeLock, 120 | self.__writeCache, 121 | {Enum.DataStoreRequestType.SetIncrementSortedAsync} 122 | ) 123 | else 124 | self.__writeLock[key] = true 125 | success = MockDataStoreManager.YieldForBudget( 126 | function() 127 | warn(("IncrementAsync request was throttled due to lack of budget. Try sending fewer requests. Key = %s") 128 | :format(key)) 129 | end, 130 | {Enum.DataStoreRequestType.SetIncrementSortedAsync} 131 | ) 132 | self.__writeLock[key] = nil 133 | end 134 | 135 | if not success then 136 | error("IncrementAsync rejected with error (request was throttled, but throttled queue was full)", 2) 137 | end 138 | 139 | local old = self.__data[key] 140 | 141 | if old ~= nil and (type(old) ~= "number" or old % 1 ~= 0) then 142 | Utils.simulateYield() 143 | error("IncrementAsync rejected with error (cannot increment non-integer value)", 2) 144 | end 145 | 146 | self.__writeLock[key] = true 147 | 148 | delta = delta and math.floor(delta + .5) or 1 149 | 150 | if old == nil then 151 | self.__data[key] = delta 152 | self.__ref[key] = {Key = key, Value = self.__data[key]} 153 | table.insert(self.__sorted, self.__ref[key]) 154 | self.__changed = true 155 | self.__event:Fire(key, self.__data[key]) 156 | elseif delta ~= 0 then 157 | self.__data[key] = self.__data[key] + delta 158 | self.__ref[key].Value = self.__data[key] 159 | self.__changed = true 160 | self.__event:Fire(key, self.__data[key]) 161 | end 162 | 163 | local retValue = self.__data[key] 164 | 165 | Utils.simulateYield() 166 | 167 | self.__writeLock[key] = nil 168 | self.__writeCache[key] = tick() 169 | 170 | self.__getCache[key] = tick() 171 | 172 | Utils.logMethod(self, "IncrementAsync", key, retValue, delta) 173 | 174 | return retValue 175 | end 176 | 177 | function MockOrderedDataStore:RemoveAsync(key) 178 | key = Utils.preprocessKey(key) 179 | if type(key) ~= "string" then 180 | error(("bad argument #1 to 'RemoveAsync' (string expected, got %s)"):format(type(key)), 2) 181 | elseif #key == 0 then 182 | error("bad argument #1 to 'RemoveAsync' (key name can't be empty)", 2) 183 | elseif #key > Constants.MAX_LENGTH_KEY then 184 | error(("bad argument #1 to 'RemoveAsync' (key name exceeds %d character limit)"):format(Constants.MAX_LENGTH_KEY), 2) 185 | end 186 | 187 | Utils.simulateErrorCheck("RemoveAsync") 188 | 189 | local success 190 | 191 | if self.__writeLock[key] or tick() - (self.__writeCache[key] or 0) < Constants.WRITE_COOLDOWN then 192 | success = MockDataStoreManager.YieldForWriteLockAndBudget( 193 | function() 194 | warn(("RemoveAsync request was throttled, a key can only be written to once every %d seconds. Key = %s") 195 | :format(Constants.WRITE_COOLDOWN, key)) 196 | end, 197 | key, 198 | self.__writeLock, 199 | self.__writeCache, 200 | {Enum.DataStoreRequestType.SetIncrementSortedAsync} 201 | ) 202 | else 203 | self.__writeLock[key] = true 204 | success = MockDataStoreManager.YieldForBudget( 205 | function() 206 | warn(("RemoveAsync request was throttled due to lack of budget. Try sending fewer requests. Key = %s") 207 | :format(key)) 208 | end, 209 | {Enum.DataStoreRequestType.SetIncrementSortedAsync} 210 | ) 211 | self.__writeLock[key] = nil 212 | end 213 | 214 | if not success then 215 | error("RemoveAsync rejected with error (request was throttled, but throttled queue was full)", 2) 216 | end 217 | 218 | self.__writeLock[key] = true 219 | 220 | local value = self.__data[key] 221 | 222 | if value ~= nil then 223 | self.__data[key] = nil 224 | self.__ref[key] = nil 225 | for i,v in pairs(self.__sorted) do 226 | if v.Key == key then 227 | table.remove(self.__sorted, i) 228 | break 229 | end 230 | end 231 | self.__event:Fire(key, nil) 232 | end 233 | 234 | Utils.simulateYield() 235 | 236 | self.__writeLock[key] = nil 237 | self.__writeCache[key] = tick() 238 | 239 | Utils.logMethod(self, "RemoveAsync", key, value) 240 | 241 | return value 242 | end 243 | 244 | function MockOrderedDataStore:SetAsync(key, value) 245 | key = Utils.preprocessKey(key) 246 | if type(key) ~= "string" then 247 | error(("bad argument #1 to 'SetAsync' (string expected, got %s)"):format(typeof(key)), 2) 248 | elseif #key == 0 then 249 | error("bad argument #1 to 'SetAsync' (key name can't be empty)", 2) 250 | elseif #key > Constants.MAX_LENGTH_KEY then 251 | error(("bad argument #1 to 'SetAsync' (key name exceeds %d character limit)"):format(Constants.MAX_LENGTH_KEY), 2) 252 | elseif type(value) ~= "number" then 253 | error(("bad argument #2 to 'SetAsync' (number expected, got %s)"):format(typeof(value)), 2) 254 | elseif value % 1 ~= 0 then 255 | error("bad argument #2 to 'SetAsync' (cannot store non-integer values in OrderedDataStore)", 2) 256 | end 257 | 258 | Utils.simulateErrorCheck("SetAsync") 259 | 260 | local success 261 | 262 | if self.__writeLock[key] or tick() - (self.__writeCache[key] or 0) < Constants.WRITE_COOLDOWN then 263 | success = MockDataStoreManager.YieldForWriteLockAndBudget( 264 | function() 265 | warn(("SetAsync request was throttled, a key can only be written to once every %d seconds. Key = %s") 266 | :format(Constants.WRITE_COOLDOWN, key)) 267 | end, 268 | key, 269 | self.__writeLock, 270 | self.__writeCache, 271 | {Enum.DataStoreRequestType.SetIncrementSortedAsync} 272 | ) 273 | else 274 | self.__writeLock[key] = true 275 | success = MockDataStoreManager.YieldForBudget( 276 | function() 277 | warn(("SetAsync request was throttled due to lack of budget. Try sending fewer requests. Key = %s") 278 | :format(key)) 279 | end, 280 | {Enum.DataStoreRequestType.SetIncrementSortedAsync} 281 | ) 282 | self.__writeLock[key] = nil 283 | end 284 | 285 | if not success then 286 | error("SetAsync rejected with error (request was throttled, but throttled queue was full)", 2) 287 | end 288 | 289 | self.__writeLock[key] = true 290 | 291 | local old = self.__data[key] 292 | 293 | if old == nil then 294 | self.__data[key] = value 295 | self.__ref[key] = {Key = key, Value = value} 296 | table.insert(self.__sorted, self.__ref[key]) 297 | self.__changed = true 298 | self.__event:Fire(key, self.__data[key]) 299 | elseif old ~= value then 300 | self.__data[key] = value 301 | self.__ref[key].Value = value 302 | self.__changed = true 303 | self.__event:Fire(key, self.__data[key]) 304 | end 305 | 306 | Utils.simulateYield() 307 | 308 | self.__writeLock[key] = nil 309 | self.__writeCache[key] = tick() 310 | 311 | Utils.logMethod(self, "SetAsync", key, self.__data[key]) 312 | 313 | return value 314 | end 315 | 316 | function MockOrderedDataStore:UpdateAsync(key, transformFunction) 317 | key = Utils.preprocessKey(key) 318 | if type(key) ~= "string" then 319 | error(("bad argument #1 to 'UpdateAsync' (string expected, got %s)"):format(typeof(key)), 2) 320 | elseif type(transformFunction) ~= "function" then 321 | error(("bad argument #2 to 'UpdateAsync' (function expected, got %s)"):format(typeof(transformFunction)), 2) 322 | elseif #key == 0 then 323 | error("bad argument #1 to 'UpdateAsync' (key name can't be empty)", 2) 324 | elseif #key > Constants.MAX_LENGTH_KEY then 325 | error(("bad argument #1 to 'UpdateAsync' (key name exceeds %d character limit)"):format(Constants.MAX_LENGTH_KEY), 2) 326 | end 327 | 328 | Utils.simulateErrorCheck("UpdateAsync") 329 | 330 | local success 331 | 332 | if self.__writeLock[key] or tick() - (self.__writeCache[key] or 0) < Constants.WRITE_COOLDOWN then 333 | success = MockDataStoreManager.YieldForWriteLockAndBudget( 334 | function() 335 | warn(("UpdateAsync request was throttled, a key can only be written to once every %d seconds. Key = %s") 336 | :format(Constants.WRITE_COOLDOWN, key)) 337 | end, 338 | key, 339 | self.__writeLock, 340 | self.__writeCache, 341 | {Enum.DataStoreRequestType.SetIncrementSortedAsync} 342 | ) 343 | else 344 | self.__writeLock[key] = true 345 | local budget 346 | if self.__getCache[key] and tick() - self.__getCache[key] < Constants.GET_COOLDOWN then 347 | budget = {Enum.DataStoreRequestType.SetIncrementSortedAsync} 348 | else 349 | budget = {Enum.DataStoreRequestType.GetAsync, Enum.DataStoreRequestType.SetIncrementSortedAsync} 350 | end 351 | success = MockDataStoreManager.YieldForBudget( 352 | function() 353 | warn(("UpdateAsync request was throttled due to lack of budget. Try sending fewer requests. Key = %s") 354 | :format(key)) 355 | end, 356 | budget 357 | ) 358 | self.__writeLock[key] = nil 359 | end 360 | 361 | if not success then 362 | error("UpdateAsync rejected with error (request was throttled, but throttled queue was full)", 2) 363 | end 364 | 365 | local value = transformFunction(self.__data[key]) 366 | 367 | if value == nil then -- cancel update after remote call 368 | Utils.simulateYield() 369 | return nil -- this is what datastores do even though it should be old value 370 | end 371 | 372 | if type(value) ~= "number" or value % 1 ~= 0 then 373 | error("UpdateAsync rejected with error (resulting non-integer value can't be stored in OrderedDataStore)", 2) 374 | end 375 | 376 | self.__writeLock[key] = true 377 | 378 | local old = self.__data[key] 379 | 380 | if old == nil then 381 | self.__data[key] = value 382 | self.__ref[key] = {Key = key, Value = value} 383 | table.insert(self.__sorted, self.__ref[key]) 384 | self.__changed = true 385 | self.__event:Fire(key, self.__data[key]) 386 | elseif old ~= value then 387 | self.__data[key] = value 388 | self.__ref[key].Value = value 389 | self.__changed = true 390 | self.__event:Fire(key, self.__data[key]) 391 | end 392 | 393 | Utils.simulateYield() 394 | 395 | self.__writeLock[key] = nil 396 | self.__writeCache[key] = tick() 397 | 398 | self.__getCache[key] = tick() 399 | 400 | Utils.logMethod(self, "UpdateAsync", key, value) 401 | 402 | return value 403 | end 404 | 405 | function MockOrderedDataStore:GetSortedAsync(ascending, pageSize, minValue, maxValue) 406 | if type(ascending) ~= "boolean" then 407 | error(("bad argument #1 to 'GetSortedAsync' (boolean expected, got %s)"):format(typeof(ascending)), 2) 408 | elseif type(pageSize) ~= "number" then 409 | error(("bad argument #2 to 'GetSortedAsync' (number expected, got %s)"):format(typeof(pageSize)), 2) 410 | end 411 | 412 | pageSize = math.floor(pageSize + .5) 413 | if pageSize <= 0 or pageSize > Constants.MAX_PAGE_SIZE then 414 | error(("bad argument #2 to 'GetSortedAsync' (page size must be an integer above 0 and below or equal to %d)") 415 | :format(Constants.MAX_PAGE_SIZE), 2) 416 | end 417 | 418 | if minValue ~= nil then 419 | if type(minValue) ~= "number" then 420 | error(("bad argument #3 to 'GetSortedAsync' (number expected, got %s)"):format(typeof(minValue)), 2) 421 | elseif minValue % 1 ~= 0 then 422 | error("bad argument #3 to 'GetSortedAsync' (minimum threshold must be an integer)", 2) 423 | end 424 | else 425 | minValue = -math.huge 426 | end 427 | 428 | if maxValue ~= nil then 429 | if type(maxValue) ~= "number" then 430 | error(("bad argument #4 to 'GetSortedAsync' (number expected, got %s)"):format(typeof(maxValue)), 2) 431 | elseif maxValue % 1 ~= 0 then 432 | error("bad argument #4 to 'GetSortedAsync' (maximum threshold must be an integer)", 2) 433 | end 434 | else 435 | maxValue = math.huge 436 | end 437 | 438 | Utils.simulateErrorCheck("GetSortedAsync") 439 | 440 | local success = MockDataStoreManager.YieldForBudget( 441 | function() 442 | warn("GetSortedAsync request was throttled due to lack of budget. Try sending fewer requests.") 443 | end, 444 | {Enum.DataStoreRequestType.GetSortedAsync} 445 | ) 446 | 447 | if not success then 448 | error("GetSortedAsync rejected with error (request was throttled, but throttled queue was full)", 2) 449 | end 450 | 451 | if minValue > maxValue then 452 | Utils.simulateYield() 453 | error("GetSortedAsync rejected with error (minimum threshold is higher than maximum threshold)", 2) 454 | end 455 | 456 | if self.__changed then 457 | table.sort(self.__sorted, function(a,b) return a.Value < b.Value end) 458 | self.__changed = false 459 | end 460 | 461 | local results = {} 462 | 463 | if ascending then 464 | local i = 1 465 | while self.__sorted[i] and self.__sorted[i].Value < minValue do 466 | i = i + 1 467 | end 468 | while self.__sorted[i] and self.__sorted[i].Value <= maxValue do 469 | table.insert(results, {key = self.__sorted[i].Key, value = self.__sorted[i].Value}) 470 | i = i + 1 471 | end 472 | else 473 | local i = #self.__sorted 474 | while i > 0 and self.__sorted[i].Value > maxValue do 475 | i = i - 1 476 | end 477 | while i > 0 and self.__sorted[i].Value >= minValue do 478 | table.insert(results, {key = self.__sorted[i].Key, value = self.__sorted[i].Value}) 479 | i = i - 1 480 | end 481 | end 482 | 483 | Utils.simulateYield() 484 | 485 | Utils.logMethod(self, "GetSortedAsync") 486 | 487 | return setmetatable({ 488 | __datastore = self; 489 | __currentPage = 1; 490 | __pageSize = pageSize; 491 | __results = results; 492 | IsFinished = (#results <= pageSize); 493 | }, MockDataStorePages) 494 | end 495 | 496 | function MockOrderedDataStore:ExportToJSON() 497 | return HttpService:JSONEncode(self.__data) 498 | end 499 | 500 | function MockOrderedDataStore:ImportFromJSON(json, verbose) 501 | local content 502 | if type(json) == "string" then 503 | local parsed, value = pcall(function() return HttpService:JSONDecode(json) end) 504 | if not parsed then 505 | error("bad argument #1 to 'ImportFromJSON' (string is not valid json)", 2) 506 | end 507 | content = value 508 | elseif type(json) == "table" then 509 | content = json -- No need to deepcopy, OrderedDataStore only contains numbers which are passed by value 510 | else 511 | error(("bad argument #1 to 'ImportFromJSON' (string or table expected, got %s)"):format(typeof(json)), 2) 512 | end 513 | 514 | if verbose ~= nil and type(verbose) ~= "boolean" then 515 | error(("bad argument #2 to 'ImportFromJSON' (boolean expected, got %s)"):format(typeof(verbose)), 2) 516 | end 517 | 518 | Utils.importPairsFromTable( 519 | content, 520 | self.__data, 521 | MockDataStoreManager.GetDataInterface(self.__data), 522 | (verbose == false and function() end or warn), 523 | "ImportFromJSON", 524 | ("OrderedDataStore > %s > %s"):format(self.__name, self.__scope), 525 | true 526 | ) 527 | end 528 | 529 | return MockOrderedDataStore 530 | -------------------------------------------------------------------------------- /lib/MockDataStoreService/init.lua: -------------------------------------------------------------------------------- 1 | --[[ 2 | MockDataStoreService.lua 3 | This module implements the API and functionality of Roblox's DataStoreService class. 4 | 5 | This module is licensed under APLv2, refer to the LICENSE file or: 6 | https://github.com/buildthomas/MockDataStoreService/blob/master/LICENSE 7 | ]] 8 | 9 | local MockDataStoreService = {} 10 | 11 | local MockDataStoreManager = require(script.MockDataStoreManager) 12 | local MockGlobalDataStore = require(script.MockGlobalDataStore) 13 | local MockOrderedDataStore = require(script.MockOrderedDataStore) 14 | local Utils = require(script.MockDataStoreUtils) 15 | local Constants = require(script.MockDataStoreConstants) 16 | local HttpService = game:GetService("HttpService") -- for json encode/decode 17 | 18 | local function makeGetWrapper(methodName, getObject, isGlobal) -- Helper function to reduce amount of redundant code 19 | return function(_, name, scope) 20 | if not game:GetService("RunService"):IsServer() then 21 | error("DataStore can't be accessed from client", 2) 22 | end 23 | 24 | if isGlobal then 25 | return getObject() 26 | else 27 | if type(name) ~= "string" then 28 | error(("bad argument #1 to '%s' (string expected, got %s)") 29 | :format(methodName, typeof(name)), 2) 30 | elseif scope ~= nil and type(scope) ~= "string" then 31 | error(("bad argument #2 to '%s' (string expected, got %s)") 32 | :format(methodName, typeof(scope)), 2) 33 | elseif #name == 0 then 34 | error(("bad argument #1 to '%s' (name can't be empty string)") 35 | :format(methodName), 2) 36 | elseif #name > Constants.MAX_LENGTH_NAME then 37 | error(("bad argument #1 to '%s' (name exceeds %d character limit)") 38 | :format(methodName, Constants.MAX_LENGTH_NAME), 2) 39 | elseif scope and #scope == 0 then 40 | error(("bad argument #2 to '%s' (scope can't be empty string)") 41 | :format(methodName), 2) 42 | elseif scope and #scope > Constants.MAX_LENGTH_SCOPE then 43 | error(("bad argument #2 to '%s' (scope exceeds %d character limit)") 44 | :format(methodName, Constants.MAX_LENGTH_SCOPE), 2) 45 | end 46 | return getObject(name, scope or "global") 47 | end 48 | 49 | end 50 | end 51 | 52 | MockDataStoreService.GetGlobalDataStore = makeGetWrapper( 53 | "GetGlobalDataStore", 54 | function() 55 | local data = MockDataStoreManager.GetGlobalData() 56 | 57 | local interface = MockDataStoreManager.GetDataInterface(data) 58 | if interface then 59 | return interface 60 | end 61 | 62 | local value = { 63 | __type = "GlobalDataStore"; 64 | __data = data; -- Mapping from to 65 | __event = Instance.new("BindableEvent"); -- For OnUpdate 66 | __writeCache = {}; 67 | __writeLock = {}; 68 | __getCache = {}; 69 | } 70 | interface = setmetatable(value, MockGlobalDataStore) 71 | MockDataStoreManager.SetDataInterface(data, interface) 72 | 73 | return interface 74 | end, 75 | true -- This is the global datastore, no name/scope needed 76 | ) 77 | 78 | MockDataStoreService.GetDataStore = makeGetWrapper( 79 | "GetDataStore", 80 | function(name, scope) 81 | local data = MockDataStoreManager.GetData(name, scope) 82 | 83 | local interface = MockDataStoreManager.GetDataInterface(data) 84 | if interface then 85 | return interface 86 | end 87 | 88 | local value = { 89 | __type = "GlobalDataStore"; 90 | __name = name; 91 | __scope = scope; 92 | __data = data; -- Mapping from to 93 | __event = Instance.new("BindableEvent"); -- For OnUpdate 94 | __writeCache = {}; 95 | __writeLock = {}; 96 | __getCache = {}; 97 | } 98 | interface = setmetatable(value, MockGlobalDataStore) 99 | MockDataStoreManager.SetDataInterface(data, interface) 100 | 101 | return interface 102 | end 103 | ) 104 | 105 | MockDataStoreService.GetOrderedDataStore = makeGetWrapper( 106 | "GetOrderedDataStore", 107 | function(name, scope) 108 | local data = MockDataStoreManager.GetOrderedData(name, scope) 109 | 110 | local interface = MockDataStoreManager.GetDataInterface(data) 111 | if interface then 112 | return interface 113 | end 114 | 115 | local value = { 116 | __type = "OrderedDataStore"; 117 | __name = name; 118 | __scope = scope; 119 | __data = data; -- Mapping from to 120 | __sorted = {}; -- List of {Key = , Value = } pairs 121 | __ref = {}; -- Mapping from to corresponding {Key = , Value = } entry in __sorted 122 | __changed = false; -- Whether __sorted is guaranteed sorted at the moment 123 | __event = Instance.new("BindableEvent"); -- For OnUpdate 124 | __writeCache = {}; 125 | __writeLock = {}; 126 | __getCache = {}; 127 | } 128 | interface = setmetatable(value, MockOrderedDataStore) 129 | MockDataStoreManager.SetDataInterface(data, interface) 130 | 131 | return interface 132 | end 133 | ) 134 | 135 | local DataStoreRequestTypes = {} 136 | 137 | for _, Enumerator in ipairs(Enum.DataStoreRequestType:GetEnumItems()) do 138 | DataStoreRequestTypes[Enumerator] = Enumerator 139 | DataStoreRequestTypes[Enumerator.Name] = Enumerator 140 | DataStoreRequestTypes[Enumerator.Value] = Enumerator 141 | end 142 | 143 | function MockDataStoreService:GetRequestBudgetForRequestType(requestType) -- luacheck: ignore self 144 | if not DataStoreRequestTypes[requestType] then 145 | error(("bad argument #1 to 'GetRequestBudgetForRequestType' (unable to cast '%s' of type %s to DataStoreRequestType)") 146 | :format(tostring(requestType), typeof(requestType)), 2) 147 | end 148 | 149 | return MockDataStoreManager.GetBudget(DataStoreRequestTypes[requestType]) 150 | end 151 | 152 | function MockDataStoreService:ImportFromJSON(json, verbose) -- luacheck: ignore self 153 | local content 154 | if type(json) == "string" then 155 | local parsed, value = pcall(function() return HttpService:JSONDecode(json) end) 156 | if not parsed then 157 | error("bad argument #1 to 'ImportFromJSON' (string is not valid json)", 2) 158 | end 159 | content = value 160 | elseif type(json) == "table" then 161 | content = Utils.deepcopy(json) 162 | else 163 | error(("bad argument #1 to 'ImportFromJSON' (string or table expected, got %s)"):format(typeof(json)), 2) 164 | end 165 | if verbose ~= nil and type(verbose) ~= "boolean" then 166 | error(("bad argument #2 to 'ImportFromJSON' (boolean expected, got %s)"):format(typeof(verbose)), 2) 167 | end 168 | 169 | return MockDataStoreManager.ImportFromJSON(content, verbose) 170 | end 171 | 172 | function MockDataStoreService:ExportToJSON() -- luacheck: ignore self 173 | return MockDataStoreManager.ExportToJSON() 174 | end 175 | 176 | return MockDataStoreService 177 | -------------------------------------------------------------------------------- /lib/init.lua: -------------------------------------------------------------------------------- 1 | --[[ 2 | DataStoreService.lua 3 | This module decides whether to use actual datastores or mock datastores depending on the environment. 4 | 5 | This module is licensed under APLv2, refer to the LICENSE file or: 6 | https://github.com/buildthomas/MockDataStoreService/blob/master/LICENSE 7 | ]] 8 | 9 | local MockDataStoreServiceModule = script.MockDataStoreService 10 | 11 | local shouldUseMock = false 12 | if game.GameId == 0 then -- Local place file 13 | shouldUseMock = true 14 | elseif game:GetService("RunService"):IsStudio() then -- Published file in Studio 15 | local status, message = pcall(function() 16 | -- This will error if current instance has no Studio API access: 17 | game:GetService("DataStoreService"):GetDataStore("__TEST"):SetAsync("__TEST", "__TEST_" .. os.time()) 18 | end) 19 | if not status and message:find("403", 1, true) then -- HACK 20 | -- Can connect to datastores, but no API access 21 | shouldUseMock = true 22 | end 23 | end 24 | 25 | -- Return the mock or actual service depending on environment: 26 | if shouldUseMock then 27 | warn("INFO: Using MockDataStoreService instead of DataStoreService") 28 | return require(MockDataStoreServiceModule) 29 | else 30 | return game:GetService("DataStoreService") 31 | end 32 | -------------------------------------------------------------------------------- /place.project.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "MockDataStoreService Test Place", 3 | "tree": { 4 | "$className": "DataModel", 5 | 6 | "ServerStorage": { 7 | "$className": "ServerStorage", 8 | 9 | "DataStoreService": { 10 | "$path": "lib" 11 | }, 12 | 13 | "TestDataStoreService": { 14 | "$path": "spec" 15 | }, 16 | 17 | "TestEZ": { 18 | "$path": "vendor/testez/lib" 19 | } 20 | }, 21 | 22 | "ServerScriptService": { 23 | "$className": "ServerScriptService", 24 | 25 | "MockDataStoreServiceTests": { 26 | "$path": "bin/run-tests.server.lua" 27 | } 28 | }, 29 | 30 | "HttpService": { 31 | "$className": "HttpService", 32 | "$properties": { 33 | "HttpEnabled": true 34 | } 35 | }, 36 | 37 | "Players": { 38 | "$className": "Players", 39 | "$properties": { 40 | "CharacterAutoLoads": false 41 | } 42 | } 43 | } 44 | } -------------------------------------------------------------------------------- /rotriever.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "buildthomas/MockDataStoreService" 3 | author = "buildthomas" 4 | license = "Apache-2.0" 5 | content_root = "lib" 6 | version = "1.0.3" 7 | -------------------------------------------------------------------------------- /spec/MockDataStoreService/MockDataStoreConstants.spec.lua: -------------------------------------------------------------------------------- 1 | return function() 2 | local Constants = require(script.Parent.Test).Constants 3 | 4 | local budgetTypes = { 5 | "BUDGET_GETASYNC"; 6 | "BUDGET_GETSORTEDASYNC"; 7 | "BUDGET_ONUPDATE"; 8 | "BUDGET_SETINCREMENTASYNC"; 9 | "BUDGET_SETINCREMENTSORTEDASYNC"; 10 | } 11 | 12 | describe("Constants list", function() 13 | 14 | it("should be a table", function() 15 | expect(Constants).to.be.a("table") 16 | end) 17 | 18 | it("should contain all plain values", function() 19 | expect(Constants.MAX_LENGTH_KEY).to.be.a("number") 20 | expect(Constants.MAX_LENGTH_NAME).to.be.a("number") 21 | expect(Constants.MAX_LENGTH_SCOPE).to.be.a("number") 22 | expect(Constants.MAX_LENGTH_DATA).to.be.a("number") 23 | expect(Constants.MAX_PAGE_SIZE).to.be.a("number") 24 | expect(Constants.YIELD_TIME_MIN).to.be.a("number") 25 | expect(Constants.YIELD_TIME_MAX).to.be.a("number") 26 | expect(Constants.YIELD_TIME_UPDATE_MIN).to.be.a("number") 27 | expect(Constants.YIELD_TIME_UPDATE_MAX).to.be.a("number") 28 | expect(Constants.WRITE_COOLDOWN).to.be.a("number") 29 | expect(Constants.GET_COOLDOWN).to.be.a("number") 30 | expect(Constants.THROTTLE_QUEUE_SIZE).to.be.a("number") 31 | expect(Constants.BUDGETING_ENABLED).to.be.a("boolean") 32 | expect(Constants.BUDGET_BASE).to.be.a("number") 33 | expect(Constants.BUDGET_ONCLOSE_BASE).to.be.a("number") 34 | expect(Constants.BUDGET_UPDATE_INTERVAL).to.be.a("number") 35 | end) 36 | 37 | it("should contain all structured values", function() 38 | for i = 1, #budgetTypes do 39 | local budgetType = budgetTypes[i] 40 | expect(Constants[budgetType]).to.be.a("table") 41 | expect(Constants[budgetType].START).to.be.a("number") 42 | expect(Constants[budgetType].RATE).to.be.a("number") 43 | expect(Constants[budgetType].RATE_PLR).to.be.a("number") 44 | expect(Constants[budgetType].MAX_FACTOR).to.be.a("number") 45 | end 46 | end) 47 | 48 | it("should have positive integer limits for characters and page size", function() 49 | expect(Constants.MAX_LENGTH_KEY % 1).to.equal(0) 50 | expect(Constants.MAX_LENGTH_KEY > 0).to.equal(true) 51 | 52 | expect(Constants.MAX_LENGTH_NAME % 1).to.equal(0) 53 | expect(Constants.MAX_LENGTH_NAME > 0).to.equal(true) 54 | 55 | expect(Constants.MAX_LENGTH_SCOPE % 1).to.equal(0) 56 | expect(Constants.MAX_LENGTH_SCOPE > 0).to.equal(true) 57 | 58 | expect(Constants.MAX_LENGTH_DATA % 1).to.equal(0) 59 | expect(Constants.MAX_LENGTH_DATA > 0).to.equal(true) 60 | 61 | expect(Constants.MAX_PAGE_SIZE % 1).to.equal(0) 62 | expect(Constants.MAX_PAGE_SIZE > 0).to.equal(true) 63 | end) 64 | 65 | it("should have positive integer limits for budgeting", function() 66 | expect(Constants.THROTTLE_QUEUE_SIZE % 1).to.equal(0) 67 | expect(Constants.THROTTLE_QUEUE_SIZE > 0).to.equal(true) 68 | 69 | for i = 1, #budgetTypes do 70 | local budgetType = budgetTypes[i] 71 | expect(Constants[budgetType].START % 1).to.equal(0) 72 | expect(Constants[budgetType].START > 0).to.equal(true) 73 | 74 | expect(Constants[budgetType].RATE % 1).to.equal(0) 75 | expect(Constants[budgetType].RATE > 0).to.equal(true) 76 | 77 | expect(Constants[budgetType].RATE_PLR % 1).to.equal(0) 78 | expect(Constants[budgetType].RATE_PLR > 0).to.equal(true) 79 | 80 | expect(Constants[budgetType].MAX_FACTOR % 1).to.equal(0) 81 | expect(Constants[budgetType].MAX_FACTOR > 0).to.equal(true) 82 | end 83 | 84 | expect(Constants.BUDGET_BASE % 1).to.equal(0) 85 | expect(Constants.BUDGET_BASE > 0).to.equal(true) 86 | 87 | expect(Constants.BUDGET_ONCLOSE_BASE % 1).to.equal(0) 88 | expect(Constants.BUDGET_ONCLOSE_BASE > 0).to.equal(true) 89 | end) 90 | 91 | it("should have starting budgets that are within the maximum limit", function() 92 | for i = 1, #budgetTypes do 93 | local budgetType = budgetTypes[i] 94 | expect(Constants[budgetType].START <= Constants[budgetType].MAX_FACTOR * Constants[budgetType].RATE) 95 | .to.equal(true) 96 | end 97 | end) 98 | 99 | it("should have non-negative time duration values", function() 100 | expect(Constants.YIELD_TIME_MIN >= 0).to.equal(true) 101 | expect(Constants.YIELD_TIME_MAX >= 0).to.equal(true) 102 | 103 | expect(Constants.YIELD_TIME_UPDATE_MIN >= 0).to.equal(true) 104 | expect(Constants.YIELD_TIME_UPDATE_MAX >= 0).to.equal(true) 105 | 106 | expect(Constants.WRITE_COOLDOWN >= 0).to.equal(true) 107 | expect(Constants.GET_COOLDOWN >= 0).to.equal(true) 108 | 109 | expect(Constants.BUDGET_UPDATE_INTERVAL >= 0).to.equal(true) 110 | end) 111 | 112 | it("should have consistent minima and maxima for yielding time values", function() 113 | expect(Constants.YIELD_TIME_MIN <= Constants.YIELD_TIME_MAX).to.equal(true) 114 | expect(Constants.YIELD_TIME_UPDATE_MIN <= Constants.YIELD_TIME_UPDATE_MAX).to.equal(true) 115 | end) 116 | 117 | end) 118 | 119 | end 120 | -------------------------------------------------------------------------------- /spec/MockDataStoreService/MockDataStorePages.spec.lua: -------------------------------------------------------------------------------- 1 | return function() 2 | local Test = require(script.Parent.Test) 3 | 4 | describe("MockDataStorePages", function() 5 | 6 | it("should expose all API members", function() 7 | Test.reset() 8 | local MockOrderedDataStore = Test.Service:GetOrderedDataStore("Test") 9 | 10 | local MockDataStorePages = MockOrderedDataStore:GetSortedAsync(true, 100) 11 | 12 | expect(MockDataStorePages.AdvanceToNextPageAsync).to.be.a("function") 13 | expect(MockDataStorePages.GetCurrentPage).to.be.a("function") 14 | expect(MockDataStorePages.IsFinished).to.be.a("boolean") 15 | 16 | end) 17 | 18 | end) 19 | 20 | describe("MockDataStorePages::AdvanceToNextPageAsync", function() 21 | 22 | it("should get all results", function() 23 | Test.reset() 24 | Test.setStaticBudgets(100) 25 | local MockOrderedDataStore = Test.Service:GetOrderedDataStore("Test") 26 | 27 | local totalResults = 1021 28 | 29 | local data = {} 30 | for i = 1, totalResults do 31 | data["TestKey"..i] = i 32 | end 33 | 34 | MockOrderedDataStore:ImportFromJSON(data) 35 | 36 | local numResults = 0 37 | local MockDataStorePages = MockOrderedDataStore:GetSortedAsync(true, 50) 38 | expect(MockDataStorePages.IsFinished).to.equal(false) 39 | repeat 40 | numResults = numResults + #MockDataStorePages:GetCurrentPage() 41 | until MockDataStorePages.IsFinished or MockDataStorePages:AdvanceToNextPageAsync() 42 | 43 | expect(numResults).to.equal(totalResults) 44 | 45 | end) 46 | 47 | it("should report correctly ordered results for ascending mode", function() 48 | Test.reset() 49 | Test.setStaticBudgets(100) 50 | local MockOrderedDataStore = Test.Service:GetOrderedDataStore("Test") 51 | 52 | local totalResults = 1000 53 | 54 | local data = {} 55 | for i = 1, totalResults do 56 | data["TestKey"..i] = i 57 | end 58 | 59 | MockOrderedDataStore:ImportFromJSON(data) 60 | 61 | local MockDataStorePages = MockOrderedDataStore:GetSortedAsync(true, 100) 62 | local previous = -math.huge 63 | repeat 64 | for _, pair in ipairs(MockDataStorePages:GetCurrentPage()) do 65 | expect(previous <= pair.value).to.equal(true) 66 | previous = pair.value 67 | end 68 | until MockDataStorePages.IsFinished or MockDataStorePages:AdvanceToNextPageAsync() 69 | 70 | end) 71 | 72 | it("should report correctly ordered results for descending mode", function() 73 | Test.reset() 74 | Test.setStaticBudgets(100) 75 | local MockOrderedDataStore = Test.Service:GetOrderedDataStore("Test") 76 | 77 | local totalResults = 1000 78 | 79 | local data = {} 80 | for i = 1, totalResults do 81 | data["TestKey"..i] = i 82 | end 83 | 84 | MockOrderedDataStore:ImportFromJSON(data) 85 | 86 | local MockDataStorePages = MockOrderedDataStore:GetSortedAsync(false, 100) 87 | local previous = math.huge 88 | repeat 89 | for _, pair in ipairs(MockDataStorePages:GetCurrentPage()) do 90 | expect(previous >= pair.value).to.equal(true) 91 | previous = pair.value 92 | end 93 | until MockDataStorePages.IsFinished or MockDataStorePages:AdvanceToNextPageAsync() 94 | 95 | end) 96 | 97 | it("should not exceed page size for each page of results", function() 98 | Test.reset() 99 | Test.setStaticBudgets(100) 100 | local MockOrderedDataStore = Test.Service:GetOrderedDataStore("Test") 101 | 102 | local totalResults = 918 103 | 104 | local data = {} 105 | for i = 1, totalResults do 106 | data["TestKey"..i] = i 107 | end 108 | 109 | MockOrderedDataStore:ImportFromJSON(data) 110 | 111 | local MockDataStorePages = MockOrderedDataStore:GetSortedAsync(true, 43) 112 | repeat 113 | if not MockDataStorePages.IsFinished then 114 | expect(#MockDataStorePages:GetCurrentPage()).to.equal(43) 115 | else 116 | expect(#MockDataStorePages:GetCurrentPage() <= 43).to.equal(true) 117 | end 118 | until MockDataStorePages.IsFinished or MockDataStorePages:AdvanceToNextPageAsync() 119 | 120 | end) 121 | 122 | it("should report values if and only if they are in range", function() 123 | Test.reset() 124 | Test.setStaticBudgets(1e3) 125 | local MockOrderedDataStore = Test.Service:GetOrderedDataStore("Test") 126 | 127 | local totalResults = 1000 128 | 129 | local data = {} 130 | for i = 1, totalResults do 131 | data["TestKey"..i] = i 132 | end 133 | 134 | MockOrderedDataStore:ImportFromJSON(data) 135 | 136 | local function test(isAscending, pageSize, minValue, maxValue) 137 | local MockDataStorePages = MockOrderedDataStore:GetSortedAsync( 138 | isAscending, 139 | pageSize, 140 | minValue, 141 | maxValue 142 | ) 143 | minValue = minValue or -math.huge 144 | maxValue = maxValue or math.huge 145 | repeat 146 | for _, pair in ipairs(MockDataStorePages:GetCurrentPage()) do 147 | expect(pair.value >= minValue).to.equal(true) 148 | expect(pair.value <= maxValue).to.equal(true) 149 | end 150 | until MockDataStorePages.IsFinished or MockDataStorePages:AdvanceToNextPageAsync() 151 | end 152 | 153 | test(true, 100, nil, -5) 154 | test(true, 100, nil, 234) 155 | test(true, 100, nil, 1592) 156 | 157 | test(false, 100, nil, -5) 158 | test(false, 100, nil, 234) 159 | test(false, 100, nil, 1592) 160 | 161 | test(true, 100, 1023, nil) 162 | test(true, 100, 689, nil) 163 | test(true, 100, -102, nil) 164 | 165 | test(false, 100, 1023, nil) 166 | test(false, 100, 689, nil) 167 | test(false, 100, -102, nil) 168 | 169 | test(true, 100, -123, -49) 170 | test(true, 100, -148, 184) 171 | test(true, 100, -94, 1194) 172 | test(true, 100, 395, 748) 173 | test(true, 100, 859, 1048) 174 | test(true, 100, 1038, 1492) 175 | 176 | test(false, 100, -123, -49) 177 | test(false, 100, -148, 184) 178 | test(false, 100, -94, 1194) 179 | test(false, 100, 395, 748) 180 | test(false, 100, 859, 1048) 181 | test(false, 100, 1038, 1492) 182 | 183 | end) 184 | 185 | it("should throw when no more pages left", function() 186 | Test.reset() 187 | Test.setStaticBudgets(100) 188 | local MockOrderedDataStore = Test.Service:GetOrderedDataStore("Test") 189 | 190 | local data = {} 191 | for i = 1, 76 do 192 | data["TestKey"..i] = i 193 | end 194 | 195 | MockOrderedDataStore:ImportFromJSON(data) 196 | 197 | local MockDataStorePages = MockOrderedDataStore:GetSortedAsync(true, 100) -- exceeds 76 198 | 199 | expect(MockDataStorePages.IsFinished).to.equal(true) 200 | expect(function() 201 | MockDataStorePages:AdvanceToNextPageAsync() 202 | end).to.throw() 203 | 204 | end) 205 | 206 | it("should consume budgets correctly", function() 207 | Test.reset() 208 | Test.setStaticBudgets(100) 209 | local MockOrderedDataStore = Test.Service:GetOrderedDataStore("Test") 210 | 211 | local data = {} 212 | for i = 1, 1000 do 213 | data["TestKey"..i] = i 214 | end 215 | 216 | MockOrderedDataStore:ImportFromJSON(data) 217 | 218 | local MockDataStorePages = MockOrderedDataStore:GetSortedAsync(true, 10) 219 | 220 | Test.setStaticBudgets(1e3) 221 | 222 | Test.captureBudget() 223 | 224 | for _ = 1, 42 do 225 | MockDataStorePages:AdvanceToNextPageAsync() 226 | expect(Test.checkpointBudget{ 227 | [Enum.DataStoreRequestType.GetSortedAsync] = -1; 228 | }).to.be.ok() 229 | end 230 | 231 | end) 232 | 233 | it("should throttle correctly when out of budget", function() 234 | -- TODO 235 | end) 236 | 237 | end) 238 | 239 | describe("MockDataStorePages::GetCurrentPage", function() 240 | 241 | it("should return a table", function() 242 | Test.reset() 243 | Test.setStaticBudgets(100) 244 | local MockOrderedDataStore = Test.Service:GetOrderedDataStore("Test") 245 | 246 | local MockDataStorePages = MockOrderedDataStore:GetSortedAsync(true, 100) 247 | 248 | expect(MockDataStorePages:GetCurrentPage()).to.be.a("table") 249 | 250 | end) 251 | 252 | it("should not allow mutation of values indirectly", function() 253 | Test.reset() 254 | Test.setStaticBudgets(100) 255 | local MockOrderedDataStore = Test.Service:GetOrderedDataStore("Test") 256 | 257 | local data = {} 258 | for i = 1, 100 do 259 | data["TestKey"..i] = i 260 | end 261 | 262 | MockOrderedDataStore:ImportFromJSON(data) 263 | 264 | local MockDataStorePages = MockOrderedDataStore:GetSortedAsync(true, 100) 265 | 266 | local result = MockDataStorePages:GetCurrentPage() 267 | 268 | result[1].value = 10001 269 | result[2].value = 10002 270 | result[3].value = 10003 271 | result[4].key = "This is not the actual key..." 272 | result[5].key = true 273 | for i = 97,100 do 274 | result[i] = nil 275 | end 276 | 277 | result = MockDataStorePages:GetCurrentPage() 278 | 279 | expect(#result).to.equal(100) 280 | for i = 1, 100 do 281 | expect(result[i]).to.be.ok() 282 | expect(result[i].key).to.equal("TestKey"..i) 283 | expect(result[i].value).to.equal(i) 284 | end 285 | 286 | end) 287 | 288 | end) 289 | 290 | end 291 | -------------------------------------------------------------------------------- /spec/MockDataStoreService/MockDataStoreUtils.spec.lua: -------------------------------------------------------------------------------- 1 | return function() 2 | local Utils = require(script.Parent.Test).Utils 3 | 4 | describe("Utils", function() 5 | 6 | it("should be a table", function() 7 | expect(Utils).to.be.a("table") 8 | end) 9 | 10 | end) 11 | 12 | describe("Utils.deepcopy", function() 13 | 14 | it("should copy flat arrays correctly", function() 15 | local array = {1, 2, 3, "Testing...", true, false} 16 | local copy = Utils.deepcopy(array) 17 | 18 | expect(copy).to.be.a("table") 19 | for i, v in pairs(array) do 20 | expect(copy[i]).to.equal(v) 21 | end 22 | for i, v in pairs(copy) do 23 | expect(array[i]).to.equal(v) 24 | end 25 | expect(#copy).to.equal(#array) 26 | end) 27 | 28 | it("should copy flat dictionaries correctly", function() 29 | local dictionary = {a = 1, b = 2, c = 3, [true] = false} 30 | local copy = Utils.deepcopy(dictionary) 31 | 32 | expect(copy).to.be.a("table") 33 | for i, v in pairs(dictionary) do 34 | expect(copy[i]).to.equal(v) 35 | end 36 | for i, v in pairs(copy) do 37 | expect(dictionary[i]).to.equal(v) 38 | end 39 | expect(#copy).to.equal(#dictionary) 40 | end) 41 | 42 | it("should copy flat mixed tables correctly", function() 43 | local mixed = { 44 | a = "Test"; 45 | 42; 46 | 1337; 47 | b = "Hello world!"; 48 | c = 123; 49 | "Testing!"; 50 | } 51 | local copy = Utils.deepcopy(mixed) 52 | 53 | expect(mixed).to.be.a("table") 54 | for i, v in pairs(copy) do 55 | expect(mixed[i]).to.equal(v) 56 | end 57 | for i, v in pairs(mixed) do 58 | expect(copy[i]).to.equal(v) 59 | end 60 | expect(#mixed).to.equal(#copy) 61 | end) 62 | 63 | it("should copy nested arrays/dictionaries/mixed tables correctly", function() 64 | local nested = { 65 | a = {42}; 66 | b = { 67 | c = 3.14; 68 | d = {"Testing", 1, 2, 3}; 69 | "w"; 70 | "t"; 71 | "f"; 72 | }; 73 | e = {}; 74 | } 75 | local copy = Utils.deepcopy(nested) 76 | 77 | expect(copy).to.be.a("table") 78 | expect(#copy).to.equal(#nested) 79 | 80 | expect(copy.a).to.be.a("table") 81 | expect(#copy.a).to.equal(#nested.a) 82 | expect(copy.a[1]).to.equal(nested.a[1]) 83 | 84 | expect(copy.b).to.be.a("table") 85 | expect(#copy.b).to.equal(#nested.b) 86 | expect(copy.b.c).to.equal(nested.b.c) 87 | expect(copy.b.d).to.a("table") 88 | for i = 1, 4 do 89 | expect(copy.b.d[i]).to.equal(nested.b.d[i]) 90 | end 91 | expect(#copy.b.d).to.equal(#nested.b.d) 92 | for i = 1, 3 do 93 | expect(copy.b[i]).to.equal(nested.b[i]) 94 | end 95 | 96 | expect(copy.e).to.be.a("table") 97 | expect(#copy.e).to.equal(#nested.e) 98 | end) 99 | 100 | end) 101 | 102 | describe("Utils.scanValidity", function() 103 | 104 | it("should report nothing for proper entries", function() 105 | local proper1 = { 106 | a = 1; 107 | b = {1, 2, 3}; 108 | c = 3; 109 | } 110 | local proper2 = { 111 | a = "Test"; 112 | b = {true, false, true}; 113 | c = "Hello world!"; 114 | } 115 | 116 | expect(Utils.scanValidity(proper1)).to.equal(true) 117 | expect(Utils.scanValidity(proper2)).to.equal(true) 118 | end) 119 | 120 | it("should report invalidly typed values", function() 121 | local testWithFunction = { 122 | a = function() end; 123 | b = 2; 124 | c = 3; 125 | } 126 | local testWithInstances = { 127 | a = 1; 128 | b = {"a", Instance.new("Frame"), "c"}; 129 | c = Instance.new("Frame"); 130 | } 131 | local testWithCoroutines = { 132 | a = 1; 133 | b = coroutine.create(function() end); 134 | c = {coroutine.create(function() end)}; 135 | } 136 | 137 | local isValid, keyPath, reason = Utils.scanValidity(testWithFunction) 138 | expect(isValid).to.equal(false) 139 | expect(keyPath).to.be.ok() 140 | expect(reason).to.be.ok() 141 | 142 | isValid, keyPath, reason = Utils.scanValidity(testWithInstances) 143 | expect(isValid).to.equal(false) 144 | expect(keyPath).to.be.ok() 145 | expect(reason).to.be.ok() 146 | 147 | isValid, keyPath, reason = Utils.scanValidity(testWithCoroutines) 148 | expect(isValid).to.equal(false) 149 | expect(keyPath).to.be.ok() 150 | expect(reason).to.be.ok() 151 | end) 152 | 153 | it("should report mixed tables", function() 154 | local mixed = { 155 | a = 1; 156 | 2; 157 | 3; 158 | } 159 | local mixedNested = { 160 | a = { "1", b = "2", "3" }; 161 | b = 2; 162 | } 163 | 164 | local isValid, keyPath, reason = Utils.scanValidity(mixed) 165 | expect(isValid).to.equal(false) 166 | expect(keyPath).to.be.ok() 167 | expect(reason).to.be.ok() 168 | 169 | isValid, keyPath, reason = Utils.scanValidity(mixedNested) 170 | expect(isValid).to.equal(false) 171 | expect(keyPath).to.be.ok() 172 | expect(reason).to.be.ok() 173 | end) 174 | 175 | it("should report array tables with holes", function() 176 | local arrayWithHoles = { 177 | [1] = "a"; 178 | [2] = "b"; 179 | [4] = "c"; 180 | [-1] = "d"; 181 | } 182 | 183 | local isValid, keyPath, reason = Utils.scanValidity(arrayWithHoles) 184 | expect(isValid).to.equal(false) 185 | expect(keyPath).to.be.ok() 186 | expect(reason).to.be.ok() 187 | end) 188 | 189 | it("should report float indices", function() 190 | local dictionaryFloatKeys = { 191 | [-1.4] = "a"; 192 | [math.pi] = "b"; 193 | [1/9] = "c"; 194 | } 195 | 196 | local isValid, keyPath, reason = Utils.scanValidity(dictionaryFloatKeys) 197 | expect(isValid).to.equal(false) 198 | expect(keyPath).to.be.ok() 199 | expect(reason).to.be.ok() 200 | end) 201 | 202 | it("should report invalidly typed indices", function() 203 | local dictionaryInvalidKeys = { 204 | [true] = "a"; 205 | [function() end] = "b"; 206 | [Instance.new("Frame")] = "c"; 207 | } 208 | 209 | local isValid, keyPath, reason = Utils.scanValidity(dictionaryInvalidKeys) 210 | expect(isValid).to.equal(false) 211 | expect(keyPath).to.be.ok() 212 | expect(reason).to.be.ok() 213 | end) 214 | 215 | it("should report cyclic tables", function() 216 | local cyclic1 = { 217 | level = { 218 | baz = 3; 219 | }; 220 | foo = 1; 221 | bar = 2; 222 | } 223 | cyclic1.level.test = cyclic1 224 | local cyclic2 = { 225 | recursion = {}; 226 | } 227 | cyclic2.recursion.recursion = cyclic2.recursion 228 | 229 | local isValid, keyPath, reason = Utils.scanValidity(cyclic1) 230 | expect(isValid).to.equal(false) 231 | expect(keyPath).to.be.ok() 232 | expect(reason).to.be.ok() 233 | 234 | isValid, keyPath, reason = Utils.scanValidity(cyclic2) 235 | expect(isValid).to.equal(false) 236 | expect(keyPath).to.be.ok() 237 | expect(reason).to.be.ok() 238 | end) 239 | 240 | it("should report infinite/-infinite indices", function() 241 | local dictionaryOutOfRangeKeys = { 242 | [-math.huge] = "Hello"; 243 | [math.huge] = "world!"; 244 | } 245 | 246 | local isValid, keyPath, reason = Utils.scanValidity(dictionaryOutOfRangeKeys) 247 | expect(isValid).to.equal(false) 248 | expect(keyPath).to.be.ok() 249 | expect(reason).to.be.ok() 250 | end) 251 | 252 | end) 253 | 254 | describe("Utils.getStringPath", function() 255 | 256 | it("should format paths in the expected way", function() 257 | local pathTable = {"foo", "bar", "baz"} 258 | 259 | expect(Utils.getStringPath(pathTable)).to.equal("foo.bar.baz") 260 | end) 261 | 262 | end) 263 | 264 | -- Utils.importPairsFromTable is not tested here, but through 265 | -- DataStoreService/GlobalDataStore/OrderedDataStore:ImportFromJSON(...) 266 | 267 | describe("Utils.prepareDataStoresForExport", function() 268 | 269 | it("should strip off empty scopes", function() 270 | local stores = { 271 | TestName = { 272 | TestScope = { 273 | Key1 = 1; 274 | Key2 = 2; 275 | Key3 = 3 276 | }; 277 | TestScope2 = {}; 278 | }; 279 | TestName2 = { 280 | TestScope = {}; 281 | TestScope2 = {}; 282 | TestScope3 = {}; 283 | }; 284 | TestName3 = {}; 285 | } 286 | 287 | stores = Utils.prepareDataStoresForExport(stores) 288 | expect(stores.TestName).to.be.ok() 289 | expect(stores.TestName.TestScope).to.be.ok() 290 | expect(stores.TestName.TestScope2).to.never.be.ok() 291 | expect(stores.TestName2).to.never.be.ok() 292 | expect(stores.TestName3).to.never.be.ok() 293 | end) 294 | 295 | it("should return nothing if entirely empty", function() 296 | local storesEmpty = {} 297 | local storesEmptyName = { 298 | TestName = {}; 299 | } 300 | local storesEmptyScopes = { 301 | TestName = { 302 | TestScope1 = {}; 303 | TestScope2 = {}; 304 | }; 305 | } 306 | 307 | expect(Utils.prepareDataStoresForExport(storesEmpty)).to.never.be.ok() 308 | expect(Utils.prepareDataStoresForExport(storesEmptyName)).to.never.be.ok() 309 | expect(Utils.prepareDataStoresForExport(storesEmptyScopes)).to.never.be.ok() 310 | end) 311 | 312 | end) 313 | 314 | end 315 | -------------------------------------------------------------------------------- /spec/MockDataStoreService/MockGlobalDataStore.spec.lua: -------------------------------------------------------------------------------- 1 | return function() 2 | local Test = require(script.Parent.Test) 3 | local HttpService = game:GetService("HttpService") 4 | 5 | describe("MockGlobalDataStore", function() 6 | 7 | it("should expose all API members", function() 8 | Test.reset() 9 | local MockGlobalDataStore = Test.Service:GetDataStore("Test") 10 | 11 | expect(MockGlobalDataStore.GetAsync).to.be.a("function") 12 | expect(MockGlobalDataStore.IncrementAsync).to.be.a("function") 13 | expect(MockGlobalDataStore.RemoveAsync).to.be.a("function") 14 | expect(MockGlobalDataStore.SetAsync).to.be.a("function") 15 | expect(MockGlobalDataStore.UpdateAsync).to.be.a("function") 16 | expect(MockGlobalDataStore.OnUpdate).to.be.a("function") 17 | 18 | end) 19 | 20 | end) 21 | 22 | describe("MockGlobalDataStore::GetAsync", function() 23 | 24 | it("should return nil for non-existing keys", function() 25 | Test.reset() 26 | Test.setStaticBudgets(100) 27 | local MockGlobalDataStore = Test.Service:GetDataStore("Test") 28 | 29 | expect(MockGlobalDataStore:GetAsync("TestKey")).to.never.be.ok() 30 | 31 | end) 32 | 33 | it("should return the value for existing keys", function() 34 | Test.reset() 35 | Test.setStaticBudgets(100) 36 | local MockGlobalDataStore = Test.Service:GetDataStore("Test") 37 | 38 | local values = { 39 | TestKey1 = 123; 40 | TestKey2 = "abc"; 41 | TestKey3 = true; 42 | TestKey4 = false; 43 | TestKey5 = 5.6; 44 | TestKey6 = {a = 1, b = 2, c = {1,2,3}, d = 4}; 45 | } 46 | 47 | MockGlobalDataStore:ImportFromJSON(values) 48 | 49 | for key, value in pairs(values) do 50 | expect(Test.subsetOf(MockGlobalDataStore:GetAsync(key), value)).to.equal(true) 51 | end 52 | 53 | end) 54 | 55 | it("should not allow mutation of stored values indirectly", function() 56 | Test.reset() 57 | Test.setStaticBudgets(100) 58 | local MockGlobalDataStore = Test.Service:GetDataStore("Test") 59 | 60 | MockGlobalDataStore:ImportFromJSON({ 61 | TestKey = {a = 1, b = 2, c = {1,2,3}, d = 4}; 62 | }) 63 | 64 | local result = MockGlobalDataStore:GetAsync("TestKey") 65 | 66 | result.a = 500; 67 | result.c[2] = 1337; 68 | 69 | result = MockGlobalDataStore:GetAsync("TestKey") 70 | 71 | expect(result.a).to.equal(1) 72 | expect(result.c[2]).to.equal(2); 73 | 74 | end) 75 | 76 | it("should consume budgets correctly", function() 77 | Test.reset() 78 | Test.setStaticBudgets(100) 79 | local MockGlobalDataStore = Test.Service:GetDataStore("Test") 80 | 81 | Test.captureBudget() 82 | 83 | for i = 1, 10 do 84 | MockGlobalDataStore:GetAsync("TestKey"..i) 85 | expect(Test.checkpointBudget{ 86 | [Enum.DataStoreRequestType.GetAsync] = -1 87 | }).to.be.ok() 88 | end 89 | 90 | end) 91 | 92 | it("should throttle requests correctly when out of budget", function() 93 | --TODO 94 | end) 95 | 96 | it("should throw for invalid input", function() 97 | Test.reset() 98 | Test.setStaticBudgets(100) 99 | local MockGlobalDataStore = Test.Service:GetDataStore("Test") 100 | 101 | expect(function() 102 | MockGlobalDataStore:GetAsync() 103 | end).to.throw() 104 | 105 | expect(function() 106 | MockGlobalDataStore:GetAsync(123) 107 | end).to.throw() 108 | 109 | expect(function() 110 | MockGlobalDataStore:GetAsync("") 111 | end).to.throw() 112 | 113 | expect(function() 114 | MockGlobalDataStore:GetAsync(("a"):rep(Test.Constants.MAX_LENGTH_KEY + 1)) 115 | end).to.throw() 116 | 117 | end) 118 | 119 | it("should set the get-cache", function() 120 | Test.reset() 121 | Test.setStaticBudgets(100) 122 | local MockGlobalDataStore = Test.Service:GetDataStore("Test") 123 | 124 | MockGlobalDataStore:GetAsync("TestKey") 125 | 126 | Test.captureBudget() 127 | 128 | MockGlobalDataStore:GetAsync("TestKey") 129 | 130 | expect(Test.checkpointBudget{}).to.be.ok() 131 | 132 | end) 133 | 134 | end) 135 | 136 | describe("MockGlobalDataStore::IncrementAsync", function() 137 | 138 | it("should increment keys", function() 139 | Test.reset() 140 | Test.setStaticBudgets(100) 141 | local MockGlobalDataStore = Test.Service:GetDataStore("Test") 142 | 143 | MockGlobalDataStore:ImportFromJSON({TestKey = 1}) 144 | 145 | MockGlobalDataStore:IncrementAsync("TestKey", 1) 146 | 147 | local export = HttpService:JSONDecode(MockGlobalDataStore:ExportToJSON()) 148 | 149 | expect(export.TestKey).to.equal(2) 150 | 151 | end) 152 | 153 | it("should increment non-existing keys", function() 154 | Test.reset() 155 | Test.setStaticBudgets(100) 156 | local MockGlobalDataStore = Test.Service:GetDataStore("Test") 157 | 158 | MockGlobalDataStore:IncrementAsync("TestKey", 1) 159 | 160 | local export = HttpService:JSONDecode(MockGlobalDataStore:ExportToJSON()) 161 | 162 | expect(export.TestKey).to.equal(1) 163 | 164 | end) 165 | 166 | it("should increment by the correct value", function() 167 | Test.reset() 168 | Test.setStaticBudgets(100) 169 | local MockGlobalDataStore = Test.Service:GetDataStore("Test") 170 | 171 | MockGlobalDataStore:ImportFromJSON({TestKey1 = 1, TestKey2 = 2, TestKey3 = 3, TestKey4 = 4, TestKey5 = 5}) 172 | 173 | MockGlobalDataStore:IncrementAsync("TestKey1", 19) 174 | MockGlobalDataStore:IncrementAsync("TestKey2", -43) 175 | MockGlobalDataStore:IncrementAsync("TestKey3", 0) 176 | MockGlobalDataStore:IncrementAsync("TestKey4", 1.5) 177 | MockGlobalDataStore:IncrementAsync("TestKey5") 178 | 179 | local export = HttpService:JSONDecode(MockGlobalDataStore:ExportToJSON()) 180 | 181 | expect(export.TestKey1).to.equal(20) 182 | expect(export.TestKey2).to.equal(-41) 183 | expect(export.TestKey3).to.equal(3) 184 | expect(export.TestKey4).to.equal(6) 185 | expect(export.TestKey5).to.equal(6) 186 | 187 | end) 188 | 189 | it("should return the incremented value", function() 190 | Test.reset() 191 | Test.setStaticBudgets(100) 192 | local MockGlobalDataStore = Test.Service:GetDataStore("Test") 193 | 194 | MockGlobalDataStore:ImportFromJSON({TestKey6 = 1938, TestKey7 = 42}) 195 | 196 | expect(MockGlobalDataStore:IncrementAsync("TestKey1")).to.be.a("number") 197 | expect(MockGlobalDataStore:IncrementAsync("TestKey2", 100)).to.be.a("number") 198 | expect(MockGlobalDataStore:IncrementAsync("TestKey3", -100)).to.be.a("number") 199 | expect(MockGlobalDataStore:IncrementAsync("TestKey4", 0)).to.be.a("number") 200 | expect(MockGlobalDataStore:IncrementAsync("TestKey5", 1.5)).to.be.a("number") 201 | expect(MockGlobalDataStore:IncrementAsync("TestKey6")).to.be.a("number") 202 | expect(MockGlobalDataStore:IncrementAsync("TestKey7", 1083)).to.be.a("number") 203 | 204 | end) 205 | 206 | it("should throw when incrementing non-number key", function() 207 | Test.reset() 208 | Test.setStaticBudgets(100) 209 | local MockGlobalDataStore = Test.Service:GetDataStore("Test") 210 | 211 | MockGlobalDataStore:ImportFromJSON({TestKey1 = {}, TestKey2 = "Hello world!", TestKey3 = true}) 212 | 213 | expect(function() 214 | MockGlobalDataStore:IncrementAsync("TestKey1") 215 | end).to.throw() 216 | 217 | expect(function() 218 | MockGlobalDataStore:IncrementAsync("TestKey2") 219 | end).to.throw() 220 | 221 | expect(function() 222 | MockGlobalDataStore:IncrementAsync("TestKey3") 223 | end).to.throw() 224 | 225 | end) 226 | 227 | it("should consume budgets correctly", function() 228 | Test.reset() 229 | Test.setStaticBudgets(100) 230 | local MockGlobalDataStore = Test.Service:GetDataStore("Test") 231 | 232 | Test.captureBudget() 233 | 234 | MockGlobalDataStore:IncrementAsync("TestKey1") 235 | expect(Test.checkpointBudget{ 236 | [Enum.DataStoreRequestType.SetIncrementAsync] = -1 237 | }).to.be.ok() 238 | 239 | MockGlobalDataStore:IncrementAsync("TestKey2", 5) 240 | expect(Test.checkpointBudget{ 241 | [Enum.DataStoreRequestType.SetIncrementAsync] = -1 242 | }).to.be.ok() 243 | 244 | MockGlobalDataStore:IncrementAsync("TestKey3", 0) 245 | expect(Test.checkpointBudget{ 246 | [Enum.DataStoreRequestType.SetIncrementAsync] = -1 247 | }).to.be.ok() 248 | 249 | MockGlobalDataStore:IncrementAsync("TestKey4", -5) 250 | expect(Test.checkpointBudget{ 251 | [Enum.DataStoreRequestType.SetIncrementAsync] = -1 252 | }).to.be.ok() 253 | 254 | end) 255 | 256 | it("should throttle requests correctly when out of budget", function() 257 | --TODO 258 | end) 259 | 260 | it("should throttle requests to respect write cooldown", function() 261 | --TODO 262 | end) 263 | 264 | it("should throw for invalid input", function() 265 | Test.reset() 266 | Test.setStaticBudgets(100) 267 | local MockGlobalDataStore = Test.Service:GetDataStore("Test") 268 | 269 | expect(function() 270 | MockGlobalDataStore:IncrementAsync() 271 | end).to.throw() 272 | 273 | expect(function() 274 | MockGlobalDataStore:IncrementAsync(123) 275 | end).to.throw() 276 | 277 | expect(function() 278 | MockGlobalDataStore:IncrementAsync("") 279 | end).to.throw() 280 | 281 | expect(function() 282 | MockGlobalDataStore:IncrementAsync(("a"):rep(Test.Constants.MAX_LENGTH_KEY + 1)) 283 | end).to.throw() 284 | 285 | expect(function() 286 | MockGlobalDataStore:IncrementAsync("Test", "Not A Number") 287 | end).to.throw() 288 | 289 | expect(function() 290 | MockGlobalDataStore:IncrementAsync(123, 1) 291 | end).to.throw() 292 | 293 | end) 294 | 295 | it("should set the get-cache", function() 296 | Test.reset() 297 | Test.setStaticBudgets(100) 298 | local MockGlobalDataStore = Test.Service:GetDataStore("Test") 299 | 300 | MockGlobalDataStore:IncrementAsync("TestKey") 301 | 302 | Test.captureBudget() 303 | 304 | MockGlobalDataStore:GetAsync("TestKey") 305 | 306 | expect(Test.checkpointBudget{}).to.be.ok() 307 | 308 | end) 309 | 310 | end) 311 | 312 | describe("MockGlobalDataStore::RemoveAsync", function() 313 | 314 | it("should be able to remove existing keys", function() 315 | Test.reset() 316 | Test.setStaticBudgets(100) 317 | local MockGlobalDataStore = Test.Service:GetDataStore("Test") 318 | 319 | MockGlobalDataStore:ImportFromJSON({ExistingKey = "Hello world!"}) 320 | 321 | MockGlobalDataStore:RemoveAsync("ExistingKey") 322 | 323 | local export = HttpService:JSONDecode(MockGlobalDataStore:ExportToJSON()) 324 | expect(export.ExistingKey).to.never.be.ok() 325 | 326 | end) 327 | 328 | it("should be able to remove non-existing keys", function() 329 | Test.reset() 330 | Test.setStaticBudgets(100) 331 | local MockGlobalDataStore = Test.Service:GetDataStore("Test") 332 | 333 | MockGlobalDataStore:RemoveAsync("NonExistingKey") 334 | 335 | local export = HttpService:JSONDecode(MockGlobalDataStore:ExportToJSON()) 336 | expect(export.NonExistingKey).to.never.be.ok() 337 | 338 | end) 339 | 340 | it("should return the old value", function() 341 | Test.reset() 342 | Test.setStaticBudgets(100) 343 | local MockGlobalDataStore = Test.Service:GetDataStore("Test") 344 | 345 | local values = { 346 | TestKey1 = "Hello world!"; 347 | TestKey2 = 123; 348 | TestKey3 = false; 349 | TestKey4 = {1,2,3}; 350 | TestKey5 = {A = {B = {}, C = 3}, D = 4, E = 5}; 351 | } 352 | 353 | MockGlobalDataStore:ImportFromJSON(values) 354 | 355 | for key, value in pairs(values) do 356 | expect(Test.subsetOf(MockGlobalDataStore:RemoveAsync(key), value)).to.equal(true) 357 | end 358 | 359 | end) 360 | 361 | it("should consume budgets correctly", function() 362 | Test.reset() 363 | Test.setStaticBudgets(100) 364 | local MockGlobalDataStore = Test.Service:GetDataStore("Test") 365 | 366 | MockGlobalDataStore:ImportFromJSON({ExistingKey = "Hello world!"}) 367 | 368 | Test.captureBudget() 369 | 370 | MockGlobalDataStore:RemoveAsync("NonExistingKey") 371 | 372 | expect(Test.checkpointBudget{ 373 | [Enum.DataStoreRequestType.SetIncrementAsync] = -1; 374 | }).to.be.ok() 375 | 376 | MockGlobalDataStore:RemoveAsync("ExistingKey") 377 | 378 | expect(Test.checkpointBudget{ 379 | [Enum.DataStoreRequestType.SetIncrementAsync] = -1; 380 | }).to.be.ok() 381 | 382 | end) 383 | 384 | it("should throttle requests correctly when out of budget", function() 385 | --TODO 386 | end) 387 | 388 | it("should throttle requests to respect write cooldown", function() 389 | --TODO 390 | end) 391 | 392 | it("should throw for invalid input", function() 393 | Test.reset() 394 | Test.setStaticBudgets(100) 395 | local MockGlobalDataStore = Test.Service:GetDataStore("Test") 396 | 397 | expect(function() 398 | MockGlobalDataStore:RemoveAsync() 399 | end).to.throw() 400 | 401 | expect(function() 402 | MockGlobalDataStore:RemoveAsync(123) 403 | end).to.throw() 404 | 405 | expect(function() 406 | MockGlobalDataStore:RemoveAsync("") 407 | end).to.throw() 408 | 409 | expect(function() 410 | MockGlobalDataStore:RemoveAsync(("a"):rep(Test.Constants.MAX_LENGTH_KEY + 1)) 411 | end).to.throw() 412 | 413 | end) 414 | 415 | it("should not set the get-cache", function() 416 | Test.reset() 417 | Test.setStaticBudgets(100) 418 | local MockGlobalDataStore = Test.Service:GetDataStore("Test") 419 | 420 | MockGlobalDataStore:RemoveAsync("TestKey") 421 | 422 | Test.captureBudget() 423 | 424 | MockGlobalDataStore:GetAsync("TestKey") 425 | 426 | expect(Test.checkpointBudget{ 427 | [Enum.DataStoreRequestType.GetAsync] = -1; 428 | }).to.be.ok() 429 | 430 | end) 431 | 432 | end) 433 | 434 | describe("MockGlobalDataStore::SetAsync", function() 435 | 436 | it("should set keys if value is valid", function() 437 | Test.reset() 438 | Test.setStaticBudgets(100) 439 | local MockGlobalDataStore = Test.Service:GetDataStore("Test") 440 | 441 | MockGlobalDataStore:ImportFromJSON({TestKey3 = "ThisShouldBeOverwritten", TestKey4 = "ThisToo"}) 442 | 443 | MockGlobalDataStore:SetAsync("TestKey1", 123) 444 | MockGlobalDataStore:SetAsync("TestKey2", "abc") 445 | MockGlobalDataStore:SetAsync("TestKey3", {a = {1,2,3}, b = {c = 1, d = 2}, e = 3}) 446 | MockGlobalDataStore:SetAsync("TestKey4", false) 447 | 448 | local exported = HttpService:JSONDecode(MockGlobalDataStore:ExportToJSON()) 449 | expect(exported.TestKey1).to.equal(123) 450 | expect(exported.TestKey2).to.equal("abc") 451 | expect(exported.TestKey3).to.be.a("table") 452 | expect(exported.TestKey4).to.equal(false) 453 | 454 | end) 455 | 456 | it("should not return anything", function() 457 | Test.reset() 458 | Test.setStaticBudgets(100) 459 | local MockGlobalDataStore = Test.Service:GetDataStore("Test") 460 | 461 | MockGlobalDataStore:ImportFromJSON({TestKey2 = "test"}) 462 | 463 | expect(MockGlobalDataStore:SetAsync("TestKey1", 123)).to.never.be.ok() 464 | expect(MockGlobalDataStore:SetAsync("TestKey2", false)).to.never.be.ok() 465 | expect(MockGlobalDataStore:SetAsync("TestKey3", "abc")).to.never.be.ok() 466 | expect(MockGlobalDataStore:SetAsync("TestKey4", {})).to.never.be.ok() 467 | 468 | end) 469 | 470 | it("should not allow mutation of stored values indirectly", function() 471 | Test.reset() 472 | Test.setStaticBudgets(100) 473 | local MockGlobalDataStore = Test.Service:GetDataStore("Test") 474 | 475 | local value = {a = {1,2,3}, b = {c = 1, d = 2}, e = 3} 476 | 477 | MockGlobalDataStore:SetAsync(value) 478 | 479 | value.a[1] = 1337 480 | value.e = "This should not be changed in the datastore" 481 | value.b.d = 42 482 | 483 | local exported = HttpService:JSONDecode(MockGlobalDataStore:ExportToJSON()) 484 | 485 | expect(exported.a[1]).to.equal(1) 486 | expect(exported.e).to.equal(3) 487 | expect(exported.b.d).to.equal(2) 488 | 489 | end) 490 | 491 | it("should consume budgets correctly", function() 492 | Test.reset() 493 | Test.setStaticBudgets(100) 494 | local MockGlobalDataStore = Test.Service:GetDataStore("Test") 495 | 496 | Test.captureBudget() 497 | 498 | MockGlobalDataStore:SetAsync("TestKey1", 123) 499 | expect(Test.checkpointBudget{ 500 | [Enum.DataStoreRequestType.SetIncrementAsync] = -1 501 | }).to.be.ok() 502 | 503 | MockGlobalDataStore:SetAsync("TestKey2", "abc") 504 | expect(Test.checkpointBudget{ 505 | [Enum.DataStoreRequestType.SetIncrementAsync] = -1 506 | }).to.be.ok() 507 | 508 | MockGlobalDataStore:SetAsync("TestKey3", {}) 509 | expect(Test.checkpointBudget{ 510 | [Enum.DataStoreRequestType.SetIncrementAsync] = -1 511 | }).to.be.ok() 512 | 513 | MockGlobalDataStore:SetAsync("TestKey4", true) 514 | expect(Test.checkpointBudget{ 515 | [Enum.DataStoreRequestType.SetIncrementAsync] = -1 516 | }).to.be.ok() 517 | 518 | end) 519 | 520 | it("should throttle requests correctly when out of budget", function() 521 | --TODO 522 | end) 523 | 524 | it("should throttle requests to respect write cooldown", function() 525 | --TODO 526 | end) 527 | 528 | it("should throw for invalid key", function() 529 | Test.reset() 530 | Test.setStaticBudgets(100) 531 | local MockGlobalDataStore = Test.Service:GetDataStore("Test") 532 | 533 | expect(function() 534 | MockGlobalDataStore:SetAsync() 535 | end).to.throw() 536 | 537 | expect(function() 538 | MockGlobalDataStore:SetAsync(nil, "value") 539 | end).to.throw() 540 | 541 | expect(function() 542 | MockGlobalDataStore:SetAsync(123, "value") 543 | end).to.throw() 544 | 545 | expect(function() 546 | MockGlobalDataStore:SetAsync("", "value") 547 | end).to.throw() 548 | 549 | expect(function() 550 | MockGlobalDataStore:SetAsync(("a"):rep(Test.Constants.MAX_LENGTH_KEY + 1), "value") 551 | end).to.throw() 552 | 553 | end) 554 | 555 | it("should throw at attempts to store invalid data", function() 556 | Test.reset() 557 | Test.setStaticBudgets(100) 558 | local MockGlobalDataStore = Test.Service:GetDataStore("Test") 559 | 560 | local function testValue(v) 561 | expect(function() 562 | MockGlobalDataStore:SetAsync("TestKey", v) 563 | end).to.throw() 564 | end 565 | 566 | testValue(nil) 567 | testValue(function() end) 568 | testValue(coroutine.create(function() end)) 569 | testValue(Instance.new("Frame")) 570 | testValue(Enum.DataStoreRequestType.GetAsync) 571 | testValue({a = 1, 2, 3}) 572 | testValue({[0] = 1, 2, 3}) 573 | testValue({[1] = 1, [2] = 2, [4] = 3}) 574 | testValue({a = {function() end, 1, 2}, b = Instance.new("Frame")}) 575 | testValue({a = {1,2,3}, b = {1,2,{{coroutine.create(function() end)},4,5}}, c = 3}) 576 | testValue(("a"):rep(Test.Constants.MAX_LENGTH_DATA + 1)) 577 | 578 | end) 579 | 580 | it("should not set the get-cache", function() 581 | Test.reset() 582 | Test.setStaticBudgets(100) 583 | local MockGlobalDataStore = Test.Service:GetDataStore("Test") 584 | 585 | MockGlobalDataStore:SetAsync("TestKey", 1) 586 | 587 | Test.captureBudget() 588 | 589 | MockGlobalDataStore:GetAsync("TestKey") 590 | 591 | expect(Test.checkpointBudget{ 592 | [Enum.DataStoreRequestType.GetAsync] = -1; 593 | }) 594 | 595 | end) 596 | 597 | end) 598 | 599 | describe("MockGlobalDataStore::UpdateAsync", function() 600 | 601 | it("should update keys correctly", function() 602 | Test.reset() 603 | Test.setStaticBudgets(100) 604 | local MockGlobalDataStore = Test.Service:GetDataStore("Test") 605 | 606 | MockGlobalDataStore:ImportFromJSON({TestKey3 = "ThisShouldBeOverwritten", TestKey4 = "ThisToo"}) 607 | 608 | MockGlobalDataStore:UpdateAsync("TestKey1", function() return 123 end) 609 | MockGlobalDataStore:UpdateAsync("TestKey2", function() return "abc" end) 610 | MockGlobalDataStore:UpdateAsync("TestKey3", function() return {a = {1,2,3}, b = {c = 1, d = 2}, e = 3} end) 611 | MockGlobalDataStore:UpdateAsync("TestKey4", function() return false end) 612 | 613 | local exported = HttpService:JSONDecode(MockGlobalDataStore:ExportToJSON()) 614 | expect(exported.TestKey1).to.equal(123) 615 | expect(exported.TestKey2).to.equal("abc") 616 | expect(exported.TestKey3).to.be.a("table") 617 | expect(exported.TestKey4).to.equal(false) 618 | 619 | end) 620 | 621 | it("should return the updated value", function() 622 | Test.reset() 623 | Test.setStaticBudgets(100) 624 | local MockGlobalDataStore = Test.Service:GetDataStore("Test") 625 | 626 | expect(MockGlobalDataStore:UpdateAsync("TestKey1", function() return 123 end)).to.equal(123) 627 | expect(MockGlobalDataStore:UpdateAsync("TestKey2", function() return false end)).to.equal(false) 628 | expect(MockGlobalDataStore:UpdateAsync("TestKey3", function() return "abc" end)).to.equal("abc") 629 | expect(MockGlobalDataStore:UpdateAsync("TestKey4", function() return {} end)).to.be.a("table") 630 | 631 | end) 632 | 633 | it("should pass the old value to the callback", function() 634 | Test.reset() 635 | Test.setStaticBudgets(100) 636 | local MockGlobalDataStore = Test.Service:GetDataStore("Test") 637 | 638 | local oldValues = { 639 | TestKey1 = "OldValue"; 640 | TestKey2 = {}; 641 | TestKey3 = false; 642 | TestKey4 = 123; 643 | } 644 | 645 | MockGlobalDataStore:ImportFromJSON(oldValues) 646 | 647 | for key, value in pairs(oldValues) do 648 | expect(MockGlobalDataStore:UpdateAsync(key, function(oldValue) 649 | if oldValue == value then 650 | return oldValue 651 | end 652 | error() 653 | end)).never.to.throw() 654 | end 655 | 656 | end) 657 | 658 | it("should not allow mutation of stored values indirectly", function() 659 | Test.reset() 660 | Test.setStaticBudgets(100) 661 | local MockGlobalDataStore = Test.Service:GetDataStore("Test") 662 | 663 | local value = {a = {1,2,3}, b = 2, c = {d = 1, e = 2}} 664 | 665 | MockGlobalDataStore:UpdateAsync("TestKey1", function() return value end) 666 | 667 | value.b = 300 668 | value.a[3] = 1337 669 | value.c.e = 200 670 | 671 | local exported = HttpService:JSONDecode(MockGlobalDataStore:ExportToJSON()) 672 | expect(exported.TestKey1).to.be.ok() 673 | expect(exported.TestKey1.b).to.equal(2) 674 | expect(exported.TestKey1.a[3]).to.equal(3) 675 | expect(exported.TestKey1.c.e).to.equal(2) 676 | 677 | MockGlobalDataStore:ImportFromJSON({TestKey2 = value}) 678 | 679 | expect(function() 680 | MockGlobalDataStore:UpdateAsync("TestKey2", function(old) 681 | old.a = 123 682 | old.b = 500 683 | old.c = 456 684 | error() 685 | end) 686 | end).to.throw() 687 | 688 | exported = HttpService:JSONDecode(MockGlobalDataStore:ExportToJSON()) 689 | expect(exported.TestKey2).to.be.ok() 690 | expect(exported.TestKey2.b).to.equal(2) 691 | expect(exported.TestKey2.a[3]).to.equal(3) 692 | expect(exported.TestKey2.c.e).to.equal(2) 693 | 694 | end) 695 | 696 | it("should consume budgets correctly", function() 697 | Test.reset() 698 | Test.setStaticBudgets(100) 699 | local MockGlobalDataStore = Test.Service:GetDataStore("Test") 700 | 701 | Test.captureBudget() 702 | 703 | for i = 1, 10 do 704 | MockGlobalDataStore:UpdateAsync("TestKey"..i, function() return 1 end) 705 | expect(Test.checkpointBudget{ 706 | [Enum.DataStoreRequestType.GetAsync] = -1; 707 | [Enum.DataStoreRequestType.SetIncrementAsync] = -1; 708 | }).to.be.ok() 709 | end 710 | 711 | end) 712 | 713 | it("should throttle requests correctly when out of budget", function() 714 | --TODO 715 | end) 716 | 717 | it("should throttle requests to respect write cooldown", function() 718 | --TODO 719 | end) 720 | 721 | it("should throw for invalid key", function() 722 | Test.reset() 723 | Test.setStaticBudgets(100) 724 | local MockGlobalDataStore = Test.Service:GetDataStore("Test") 725 | 726 | local func = function() return 1 end 727 | 728 | expect(function() 729 | MockGlobalDataStore:UpdateAsync() 730 | end).to.throw() 731 | 732 | expect(function() 733 | MockGlobalDataStore:UpdateAsync(nil, func) 734 | end).to.throw() 735 | 736 | expect(function() 737 | MockGlobalDataStore:UpdateAsync(123, func) 738 | end).to.throw() 739 | 740 | expect(function() 741 | MockGlobalDataStore:UpdateAsync("", func) 742 | end).to.throw() 743 | 744 | expect(function() 745 | MockGlobalDataStore:UpdateAsync(("a"):rep(Test.Constants.MAX_LENGTH_KEY + 1), func) 746 | end).to.throw() 747 | 748 | end) 749 | 750 | it("should throw at attempts to store invalid data", function() 751 | Test.reset() 752 | Test.setStaticBudgets(100) 753 | local MockGlobalDataStore = Test.Service:GetDataStore("Test") 754 | 755 | local function testValue(v) 756 | expect(function() 757 | MockGlobalDataStore:UpdateAsync("TestKey", function() return v end) 758 | end).to.throw() 759 | end 760 | 761 | testValue(nil) 762 | testValue(function() end) 763 | testValue(coroutine.create(function() end)) 764 | testValue(Instance.new("Frame")) 765 | testValue(Enum.DataStoreRequestType.GetAsync) 766 | testValue({a = 1, 2, 3}) 767 | testValue({[0] = 1, 2, 3}) 768 | testValue({[1] = 1, [2] = 2, [4] = 3}) 769 | testValue({a = {function() end, 1, 2}, b = Instance.new("Frame")}) 770 | testValue({a = {1,2,3}, b = {1,2,{{coroutine.create(function() end)},4,5}}, c = 3}) 771 | testValue(("a"):rep(Test.Constants.MAX_LENGTH_DATA + 1)) 772 | 773 | end) 774 | 775 | it("should set the get-cache", function() 776 | Test.reset() 777 | Test.setStaticBudgets(100) 778 | local MockGlobalDataStore = Test.Service:GetDataStore("Test") 779 | 780 | MockGlobalDataStore:UpdateAsync("TestKey", function() return 1 end) 781 | MockGlobalDataStore:GetAsync("TestKey") 782 | 783 | expect(Test.Manager.GetBudget(Enum.DataStoreRequestType.GetAsync)).to.equal(1) 784 | 785 | end) 786 | 787 | end) 788 | 789 | describe("MockGlobalDataStore::OnUpdate", function() 790 | 791 | it("should return a RBXScriptConnection", function() 792 | Test.reset() 793 | Test.setStaticBudgets(100) 794 | local MockGlobalDataStore = Test.Service:GetDataStore("Test") 795 | 796 | local conn = MockGlobalDataStore:OnUpdate("TestKey") 797 | 798 | conn:Disconnect() -- don't leak after test 799 | 800 | expect(conn).to.be.a("RBXScriptConnection") 801 | 802 | end) 803 | 804 | it("should only receives updates for its connected key", function() 805 | --TODO 806 | end) 807 | 808 | it("should work with SetAsync", function() 809 | --TODO 810 | end) 811 | 812 | it("should work with UpdateAsync", function() 813 | --TODO 814 | end) 815 | 816 | it("should work with RemoveAsync", function() 817 | --TODO 818 | end) 819 | 820 | it("should work with IncrementAsync", function() 821 | --TODO 822 | end) 823 | 824 | it("should not fire callback after disconnecting", function() 825 | --TODO 826 | end) 827 | 828 | it("should not allow mutation of stored values indirectly", function() 829 | --TODO 830 | end) 831 | 832 | it("should consume budgets correctly", function() 833 | Test.reset() 834 | Test.setStaticBudgets(100) 835 | local MockGlobalDataStore = Test.Service:GetDataStore("Test") 836 | 837 | Test.captureBudget() 838 | 839 | for i = 1, 10 do 840 | local conn = MockGlobalDataStore:OnUpdate("TestKey"..i, function() end) 841 | conn:Disconnect() 842 | expect(Test.checkpointBudget{ 843 | [Enum.DataStoreRequestType.OnUpdate] = -1; 844 | }).to.be.ok() 845 | end 846 | 847 | end) 848 | 849 | it("should throttle requests correctly when out of budget", function() 850 | --TODO 851 | end) 852 | 853 | it("should throw for invalid input", function() 854 | Test.reset() 855 | Test.setStaticBudgets(100) 856 | local MockGlobalDataStore = Test.Service:GetDataStore("Test") 857 | 858 | expect(function() 859 | MockGlobalDataStore:OnUpdate() 860 | end).to.throw() 861 | 862 | expect(function() 863 | MockGlobalDataStore:OnUpdate(123) 864 | end).to.throw() 865 | 866 | expect(function() 867 | MockGlobalDataStore:OnUpdate("") 868 | end).to.throw() 869 | 870 | expect(function() 871 | MockGlobalDataStore:OnUpdate(("a"):rep(Test.Constants.MAX_LENGTH_KEY + 1)) 872 | end).to.throw() 873 | 874 | expect(function() 875 | MockGlobalDataStore:OnUpdate("Test", 123) 876 | end).to.throw() 877 | 878 | expect(function() 879 | MockGlobalDataStore:OnUpdate(123, function() end) 880 | end).to.throw() 881 | 882 | end) 883 | 884 | it("should not set the get-cache", function() 885 | Test.reset() 886 | Test.setStaticBudgets(100) 887 | local MockGlobalDataStore = Test.Service:GetDataStore("Test") 888 | 889 | local connection = MockGlobalDataStore:OnUpdate("TestKey", function() end) 890 | 891 | Test.captureBudget() 892 | 893 | local result = expect(function() 894 | MockGlobalDataStore:GetAsync("TestKey") 895 | end) 896 | 897 | if connection then 898 | connection:Disconnect() 899 | end 900 | 901 | expect(result.never.to.throw()) 902 | expect(Test.checkpointBudget{ 903 | [Enum.DataStoreRequestType.GetAsync] = -1; 904 | }).to.be.ok() 905 | 906 | end) 907 | 908 | end) 909 | 910 | local scope1 = { 911 | TestKey1 = true; 912 | TestKey2 = "Hello world!"; 913 | TestKey3 = {First = 1, Second = 2, Third = 3}; 914 | TestKey4 = false; 915 | } 916 | 917 | local scope2 = {} 918 | 919 | local scope3 = { 920 | TestKey1 = "Test string"; 921 | TestKey2 = { 922 | First = {First = "Hello"}; 923 | Second = {First = true, Second = false}; 924 | Third = 3; 925 | Fourth = {"One", 1, "Two", 2, "Three", {3, 4, 5, 6}, 7}; 926 | }; 927 | TestKey3 = 12345; 928 | } 929 | 930 | local scope4 = { 931 | TestKey1 = 1; 932 | TestKey2 = 2; 933 | TestKey3 = 3; 934 | } 935 | 936 | local scope5 = { 937 | TestImportKey1 = -5.1; 938 | TestImportKey2 = "Test string"; 939 | TestImportKey3 = {}; 940 | } 941 | 942 | describe("MockGlobalDataStore::ImportFromJSON/ExportToJSON", function() 943 | 944 | it("should import keys correctly", function() 945 | Test.reset() 946 | local MockGlobalDataStore = Test.Service:GetDataStore("Test") 947 | 948 | expect(function() 949 | 950 | MockGlobalDataStore:ImportFromJSON(scope1, false) 951 | MockGlobalDataStore:ImportFromJSON(scope2, false) 952 | MockGlobalDataStore:ImportFromJSON(scope3, false) 953 | MockGlobalDataStore:ImportFromJSON(scope4, false) 954 | MockGlobalDataStore:ImportFromJSON(scope5, false) 955 | 956 | MockGlobalDataStore:ImportFromJSON(HttpService:JSONEncode(scope1), false) 957 | MockGlobalDataStore:ImportFromJSON(HttpService:JSONEncode(scope2), false) 958 | MockGlobalDataStore:ImportFromJSON(HttpService:JSONEncode(scope3), false) 959 | MockGlobalDataStore:ImportFromJSON(HttpService:JSONEncode(scope4), false) 960 | MockGlobalDataStore:ImportFromJSON(HttpService:JSONEncode(scope5), false) 961 | 962 | end).never.to.throw() 963 | 964 | end) 965 | 966 | it("should contain all imported values afterwards", function() 967 | Test.reset() 968 | local MockGlobalDataStore = Test.Service:GetDataStore("Test") 969 | 970 | MockGlobalDataStore:ImportFromJSON(scope3, false) 971 | MockGlobalDataStore:ImportFromJSON(scope5, false) 972 | 973 | local exported = HttpService:JSONDecode(MockGlobalDataStore:ExportToJSON()) 974 | expect(Test.subsetOf(scope3, exported)).to.equal(true) 975 | expect(Test.subsetOf(scope5, exported)).to.equal(true) 976 | 977 | end) 978 | 979 | it("should fire OnUpdate signals", function() 980 | --TODO 981 | end) 982 | 983 | it("should ignore invalid values and keys", function() 984 | Test.reset() 985 | local MockGlobalDataStore = Test.Service:GetDataStore("Test") 986 | 987 | local partiallyValid = { 988 | TestKey1 = 1; 989 | TestKey2 = true; 990 | TestKey3 = "Test"; 991 | TestKey4 = {1,2,3,4}; 992 | TestKey5 = {}; -- will loop 993 | TestKey6 = 6; 994 | [true] = 7; 995 | [123] = "Hello"; 996 | TestKey8 = Instance.new("Frame"); 997 | TestKey9 = math.huge; 998 | TestKey10 = -math.huge; 999 | TestKey11 = 11; 1000 | TestKey12 = function() end; 1001 | TestKey13 = {a = 1, 2, 3}; 1002 | TestKey14 = {[1] = 1, [2] = 2, [4] = 3}; 1003 | TestKey15 = {a = {1,2,3}, b = 4, c = {{1,2},3,4,5,{6,7}}, d = "Testing"}; 1004 | TestKey16 = ("a"):rep(Test.Constants.MAX_LENGTH_DATA + 1); 1005 | TestKey17 = {a = {1,2,3}, b = 4, c = {{1,2},3,4,5,{6,7, coroutine.create(function() end)}}}; 1006 | } 1007 | partiallyValid.TestKey5.loop = partiallyValid.TestKey5 1008 | 1009 | MockGlobalDataStore:ImportFromJSON(partiallyValid) 1010 | 1011 | local exported = HttpService:JSONDecode(MockGlobalDataStore:ExportToJSON()) 1012 | 1013 | expect(exported.TestKey1).to.be.ok() 1014 | expect(exported.TestKey2).to.be.ok() 1015 | expect(exported.TestKey3).to.be.ok() 1016 | expect(exported.TestKey4).to.be.ok() 1017 | expect(exported.TestKey5).to.never.be.ok() 1018 | expect(exported.TestKey6).to.be.ok() 1019 | expect(exported[true]).to.never.be.ok() 1020 | expect(exported[123]).to.never.be.ok() 1021 | expect(exported.TestKey8).to.never.be.ok() 1022 | expect(exported.TestKey9).to.never.be.ok() 1023 | expect(exported.TestKey10).to.never.be.ok() 1024 | expect(exported.TestKey11).to.be.ok() 1025 | expect(exported.TestKey12).to.never.be.ok() 1026 | expect(exported.TestKey13).to.never.be.ok() 1027 | expect(exported.TestKey14).to.never.be.ok() 1028 | expect(exported.TestKey15).to.be.ok() 1029 | expect(exported.TestKey16).to.never.be.ok() 1030 | expect(exported.TestKey17).to.never.be.ok() 1031 | 1032 | end) 1033 | 1034 | it("should throw for invalid input", function() 1035 | Test.reset() 1036 | local MockGlobalDataStore = Test.Service:GetDataStore("Test") 1037 | 1038 | expect(function() 1039 | MockGlobalDataStore:ImportFromJSON("{this is invalid json}", false) 1040 | end).to.throw() 1041 | 1042 | expect(function() 1043 | MockGlobalDataStore:ImportFromJSON(123, false) 1044 | end).to.throw() 1045 | 1046 | expect(function() 1047 | MockGlobalDataStore:ImportFromJSON({}, 123) 1048 | end).to.throw() 1049 | 1050 | expect(function() 1051 | MockGlobalDataStore:ImportFromJSON("{}", 123) 1052 | end).to.throw() 1053 | 1054 | end) 1055 | 1056 | end) 1057 | 1058 | end 1059 | -------------------------------------------------------------------------------- /spec/MockDataStoreService/MockOrderedDataStore.spec.lua: -------------------------------------------------------------------------------- 1 | return function() 2 | local Test = require(script.Parent.Test) 3 | local HttpService = game:GetService("HttpService") 4 | 5 | describe("MockOrderedDataStore", function() 6 | 7 | it("should expose all API members", function() 8 | Test.reset() 9 | local MockOrderedDataStore = Test.Service:GetOrderedDataStore("Test", "Test") 10 | 11 | expect(MockOrderedDataStore.GetAsync).to.be.a("function") 12 | expect(MockOrderedDataStore.IncrementAsync).to.be.a("function") 13 | expect(MockOrderedDataStore.RemoveAsync).to.be.a("function") 14 | expect(MockOrderedDataStore.SetAsync).to.be.a("function") 15 | expect(MockOrderedDataStore.UpdateAsync).to.be.a("function") 16 | expect(MockOrderedDataStore.OnUpdate).to.be.a("function") 17 | expect(MockOrderedDataStore.GetSortedAsync).to.be.a("function") 18 | 19 | end) 20 | 21 | end) 22 | 23 | describe("MockOrderedDataStore::GetAsync", function() 24 | 25 | it("should return nil for non-existing keys", function() 26 | Test.reset() 27 | Test.setStaticBudgets(100) 28 | local MockOrderedDataStore = Test.Service:GetOrderedDataStore("Test") 29 | 30 | expect(MockOrderedDataStore:GetAsync("TestKey")).to.never.be.ok() 31 | 32 | end) 33 | 34 | it("should return the value for existing keys", function() 35 | Test.reset() 36 | Test.setStaticBudgets(100) 37 | local MockOrderedDataStore = Test.Service:GetOrderedDataStore("Test") 38 | 39 | MockOrderedDataStore:ImportFromJSON({ 40 | TestKey1 = -123; 41 | TestKey2 = 0; 42 | TestKey3 = 291; 43 | }) 44 | 45 | expect(MockOrderedDataStore:GetAsync("TestKey1")).to.equal(-123) 46 | expect(MockOrderedDataStore:GetAsync("TestKey2")).to.equal(0) 47 | expect(MockOrderedDataStore:GetAsync("TestKey3")).to.equal(291) 48 | 49 | end) 50 | 51 | it("should consume budgets correctly", function() 52 | Test.reset() 53 | Test.setStaticBudgets(100) 54 | local MockOrderedDataStore = Test.Service:GetOrderedDataStore("Test") 55 | 56 | Test.captureBudget() 57 | 58 | for i = 1, 10 do 59 | MockOrderedDataStore:GetAsync("TestKey"..i) 60 | expect(Test.checkpointBudget{ 61 | [Enum.DataStoreRequestType.GetAsync] = -1 62 | }).to.be.ok() 63 | end 64 | 65 | end) 66 | 67 | it("should throttle requests correctly when out of budget", function() 68 | --TODO 69 | end) 70 | 71 | it("should throw for invalid input", function() 72 | Test.reset() 73 | Test.setStaticBudgets(100) 74 | local MockOrderedDataStore = Test.Service:GetOrderedDataStore("Test") 75 | 76 | expect(function() 77 | MockOrderedDataStore:GetAsync() 78 | end).to.throw() 79 | 80 | expect(function() 81 | MockOrderedDataStore:GetAsync(123) 82 | end).to.throw() 83 | 84 | expect(function() 85 | MockOrderedDataStore:GetAsync("") 86 | end).to.throw() 87 | 88 | expect(function() 89 | MockOrderedDataStore:GetAsync(("a"):rep(Test.Constants.MAX_LENGTH_KEY + 1)) 90 | end).to.throw() 91 | 92 | end) 93 | 94 | it("should set the get-cache", function() 95 | Test.reset() 96 | Test.setStaticBudgets(100) 97 | local MockOrderedDataStore = Test.Service:GetOrderedDataStore("Test") 98 | 99 | MockOrderedDataStore:GetAsync("TestKey") 100 | 101 | Test.captureBudget() 102 | 103 | MockOrderedDataStore:GetAsync("TestKey") 104 | 105 | expect(Test.checkpointBudget{}).to.be.ok() 106 | 107 | end) 108 | 109 | end) 110 | 111 | describe("MockOrderedDataStore::IncrementAsync", function() 112 | 113 | it("should increment keys", function() 114 | Test.reset() 115 | Test.setStaticBudgets(100) 116 | local MockOrderedDataStore = Test.Service:GetOrderedDataStore("Test") 117 | 118 | MockOrderedDataStore:ImportFromJSON({TestKey = 1}) 119 | 120 | MockOrderedDataStore:IncrementAsync("TestKey", 1) 121 | 122 | local export = HttpService:JSONDecode(MockOrderedDataStore:ExportToJSON()) 123 | 124 | expect(export.TestKey).to.equal(2) 125 | 126 | end) 127 | 128 | it("should increment non-existing keys", function() 129 | Test.reset() 130 | Test.setStaticBudgets(100) 131 | local MockOrderedDataStore = Test.Service:GetOrderedDataStore("Test") 132 | 133 | MockOrderedDataStore:IncrementAsync("TestKey", 1) 134 | 135 | local export = HttpService:JSONDecode(MockOrderedDataStore:ExportToJSON()) 136 | 137 | expect(export.TestKey).to.equal(1) 138 | 139 | end) 140 | 141 | it("should increment by the correct value", function() 142 | Test.reset() 143 | Test.setStaticBudgets(100) 144 | local MockOrderedDataStore = Test.Service:GetOrderedDataStore("Test") 145 | 146 | MockOrderedDataStore:ImportFromJSON({TestKey1 = 1, TestKey2 = 2, TestKey3 = 3, TestKey4 = 4, TestKey5 = 5}) 147 | 148 | MockOrderedDataStore:IncrementAsync("TestKey1", 19) 149 | MockOrderedDataStore:IncrementAsync("TestKey2", -43) 150 | MockOrderedDataStore:IncrementAsync("TestKey3", 0) 151 | MockOrderedDataStore:IncrementAsync("TestKey4", 1.5) 152 | MockOrderedDataStore:IncrementAsync("TestKey5") 153 | 154 | local export = HttpService:JSONDecode(MockOrderedDataStore:ExportToJSON()) 155 | 156 | expect(export.TestKey1).to.equal(20) 157 | expect(export.TestKey2).to.equal(-41) 158 | expect(export.TestKey3).to.equal(3) 159 | expect(export.TestKey4).to.equal(6) 160 | expect(export.TestKey5).to.equal(6) 161 | 162 | end) 163 | 164 | it("should return the incremented value", function() 165 | Test.reset() 166 | Test.setStaticBudgets(100) 167 | local MockOrderedDataStore = Test.Service:GetOrderedDataStore("Test") 168 | 169 | MockOrderedDataStore:ImportFromJSON({TestKey6 = 1938, TestKey7 = 42}) 170 | 171 | expect(MockOrderedDataStore:IncrementAsync("TestKey1")).to.be.a("number") 172 | expect(MockOrderedDataStore:IncrementAsync("TestKey2", 100)).to.be.a("number") 173 | expect(MockOrderedDataStore:IncrementAsync("TestKey3", -100)).to.be.a("number") 174 | expect(MockOrderedDataStore:IncrementAsync("TestKey4", 0)).to.be.a("number") 175 | expect(MockOrderedDataStore:IncrementAsync("TestKey5", 1.5)).to.be.a("number") 176 | expect(MockOrderedDataStore:IncrementAsync("TestKey6")).to.be.a("number") 177 | expect(MockOrderedDataStore:IncrementAsync("TestKey7", 1083)).to.be.a("number") 178 | 179 | end) 180 | 181 | it("should consume budgets correctly", function() 182 | Test.reset() 183 | Test.setStaticBudgets(100) 184 | local MockOrderedDataStore = Test.Service:GetOrderedDataStore("Test") 185 | 186 | MockOrderedDataStore:ImportFromJSON({TestKey2 = 10}) 187 | 188 | Test.captureBudget() 189 | 190 | MockOrderedDataStore:IncrementAsync("TestKey1") 191 | expect(Test.checkpointBudget{ 192 | [Enum.DataStoreRequestType.SetIncrementAsync] = -1 193 | }).to.be.ok() 194 | 195 | MockOrderedDataStore:IncrementAsync("TestKey2", 5) 196 | expect(Test.checkpointBudget{ 197 | [Enum.DataStoreRequestType.SetIncrementAsync] = -1 198 | }).to.be.ok() 199 | 200 | MockOrderedDataStore:IncrementAsync("TestKey3", 0) 201 | expect(Test.checkpointBudget{ 202 | [Enum.DataStoreRequestType.SetIncrementAsync] = -1 203 | }).to.be.ok() 204 | 205 | MockOrderedDataStore:IncrementAsync("TestKey4", -5) 206 | expect(Test.checkpointBudget{ 207 | [Enum.DataStoreRequestType.SetIncrementAsync] = -1 208 | }).to.be.ok() 209 | 210 | end) 211 | 212 | it("should throttle requests correctly when out of budget", function() 213 | --TODO 214 | end) 215 | 216 | it("should throttle requests to respect write cooldown", function() 217 | --TODO 218 | end) 219 | 220 | it("should throw for invalid input", function() 221 | Test.reset() 222 | Test.setStaticBudgets(100) 223 | local MockOrderedDataStore = Test.Service:GetOrderedDataStore("Test") 224 | 225 | expect(function() 226 | MockOrderedDataStore:IncrementAsync() 227 | end).to.throw() 228 | 229 | expect(function() 230 | MockOrderedDataStore:IncrementAsync(123) 231 | end).to.throw() 232 | 233 | expect(function() 234 | MockOrderedDataStore:IncrementAsync("") 235 | end).to.throw() 236 | 237 | expect(function() 238 | MockOrderedDataStore:IncrementAsync(("a"):rep(Test.Constants.MAX_LENGTH_KEY + 1)) 239 | end).to.throw() 240 | 241 | expect(function() 242 | MockOrderedDataStore:IncrementAsync("Test", "Not A Number") 243 | end).to.throw() 244 | 245 | expect(function() 246 | MockOrderedDataStore:IncrementAsync(123, 1) 247 | end).to.throw() 248 | 249 | end) 250 | 251 | it("should set the get-cache", function() 252 | Test.reset() 253 | Test.setStaticBudgets(100) 254 | local MockOrderedDataStore = Test.Service:GetOrderedDataStore("Test") 255 | 256 | MockOrderedDataStore:IncrementAsync("TestKey") 257 | 258 | Test.captureBudget() 259 | 260 | MockOrderedDataStore:GetAsync("TestKey") 261 | 262 | expect(Test.checkpointBudget{}).to.be.ok() 263 | 264 | end) 265 | 266 | end) 267 | 268 | describe("MockOrderedDataStore::RemoveAsync", function() 269 | 270 | it("should be able to remove existing keys", function() 271 | Test.reset() 272 | Test.setStaticBudgets(100) 273 | local MockOrderedDataStore = Test.Service:GetOrderedDataStore("Test") 274 | 275 | MockOrderedDataStore:ImportFromJSON({ExistingKey = 1}) 276 | 277 | MockOrderedDataStore:RemoveAsync("ExistingKey") 278 | 279 | local export = HttpService:JSONDecode(MockOrderedDataStore:ExportToJSON()) 280 | expect(export.ExistingKey).to.never.be.ok() 281 | 282 | end) 283 | 284 | it("should be able to remove non-existing keys", function() 285 | Test.reset() 286 | Test.setStaticBudgets(100) 287 | local MockOrderedDataStore = Test.Service:GetOrderedDataStore("Test") 288 | 289 | MockOrderedDataStore:RemoveAsync("NonExistingKey") 290 | 291 | local export = HttpService:JSONDecode(MockOrderedDataStore:ExportToJSON()) 292 | expect(export.NonExistingKey).to.never.be.ok() 293 | 294 | end) 295 | 296 | it("should return the old value", function() 297 | Test.reset() 298 | Test.setStaticBudgets(100) 299 | local MockOrderedDataStore = Test.Service:GetOrderedDataStore("Test") 300 | 301 | local values = { 302 | TestKey1 = 123; 303 | } 304 | 305 | MockOrderedDataStore:ImportFromJSON(values) 306 | 307 | expect(MockOrderedDataStore:RemoveAsync("TestKey1")).to.equal(values.TestKey1) 308 | 309 | end) 310 | 311 | it("should consume budgets correctly", function() 312 | Test.reset() 313 | Test.setStaticBudgets(100) 314 | local MockOrderedDataStore = Test.Service:GetOrderedDataStore("Test") 315 | 316 | MockOrderedDataStore:ImportFromJSON({ExistingKey = 42}) 317 | 318 | Test.captureBudget() 319 | 320 | MockOrderedDataStore:RemoveAsync("NonExistingKey") 321 | 322 | expect(Test.checkpointBudget{ 323 | [Enum.DataStoreRequestType.SetIncrementAsync] = -1; 324 | }).to.be.ok() 325 | 326 | MockOrderedDataStore:RemoveAsync("ExistingKey") 327 | 328 | expect(Test.checkpointBudget{ 329 | [Enum.DataStoreRequestType.SetIncrementAsync] = -1; 330 | }).to.be.ok() 331 | 332 | end) 333 | 334 | it("should throttle requests correctly when out of budget", function() 335 | --TODO 336 | end) 337 | 338 | it("should throttle requests to respect write cooldown", function() 339 | --TODO 340 | end) 341 | 342 | it("should throw for invalid input", function() 343 | Test.reset() 344 | Test.setStaticBudgets(100) 345 | local MockOrderedDataStore = Test.Service:GetOrderedDataStore("Test") 346 | 347 | expect(function() 348 | MockOrderedDataStore:RemoveAsync() 349 | end).to.throw() 350 | 351 | expect(function() 352 | MockOrderedDataStore:RemoveAsync(123) 353 | end).to.throw() 354 | 355 | expect(function() 356 | MockOrderedDataStore:RemoveAsync("") 357 | end).to.throw() 358 | 359 | expect(function() 360 | MockOrderedDataStore:RemoveAsync(("a"):rep(Test.Constants.MAX_LENGTH_KEY + 1)) 361 | end).to.throw() 362 | 363 | end) 364 | 365 | it("should not set the get-cache", function() 366 | Test.reset() 367 | Test.setStaticBudgets(100) 368 | local MockOrderedDataStore = Test.Service:GetOrderedDataStore("Test") 369 | 370 | MockOrderedDataStore:RemoveAsync("TestKey") 371 | 372 | Test.captureBudget() 373 | 374 | MockOrderedDataStore:GetAsync("TestKey") 375 | 376 | expect(Test.checkpointBudget{ 377 | [Enum.DataStoreRequestType.GetAsync] = -1; 378 | }).to.equal(true) 379 | 380 | end) 381 | 382 | end) 383 | 384 | describe("MockOrderedDataStore::SetAsync", function() 385 | 386 | it("should set keys if value is valid", function() 387 | Test.reset() 388 | Test.setStaticBudgets(100) 389 | local MockOrderedDataStore = Test.Service:GetOrderedDataStore("Test") 390 | 391 | MockOrderedDataStore:ImportFromJSON({TestKey2 = 42}) 392 | 393 | MockOrderedDataStore:SetAsync("TestKey1", 100) 394 | MockOrderedDataStore:SetAsync("TestKey2", 200) 395 | MockOrderedDataStore:SetAsync("TestKey3", -300) 396 | MockOrderedDataStore:SetAsync("TestKey4", 0) 397 | 398 | local exported = HttpService:JSONDecode(MockOrderedDataStore:ExportToJSON()) 399 | expect(exported.TestKey1).to.equal(100) 400 | expect(exported.TestKey2).to.equal(200) 401 | expect(exported.TestKey3).to.equal(-300) 402 | expect(exported.TestKey4).to.equal(0) 403 | 404 | end) 405 | 406 | it("should not return anything", function() 407 | Test.reset() 408 | Test.setStaticBudgets(100) 409 | local MockOrderedDataStore = Test.Service:GetOrderedDataStore("Test") 410 | 411 | MockOrderedDataStore:ImportFromJSON({TestKey2 = 42}) 412 | 413 | expect(MockOrderedDataStore:SetAsync("TestKey1", 100)).to.never.be.ok() 414 | expect(MockOrderedDataStore:SetAsync("TestKey2", -100)).to.never.be.ok() 415 | expect(MockOrderedDataStore:SetAsync("TestKey3", 0)).to.never.be.ok() 416 | 417 | end) 418 | 419 | it("should consume budgets correctly", function() 420 | Test.reset() 421 | Test.setStaticBudgets(100) 422 | local MockOrderedDataStore = Test.Service:GetOrderedDataStore("Test") 423 | 424 | Test.captureBudget() 425 | 426 | MockOrderedDataStore:SetAsync("TestKey1", 123) 427 | expect(Test.checkpointBudget{ 428 | [Enum.DataStoreRequestType.SetIncrementAsync] = -1 429 | }).to.be.ok() 430 | 431 | MockOrderedDataStore:SetAsync("TestKey2", -123) 432 | expect(Test.checkpointBudget{ 433 | [Enum.DataStoreRequestType.SetIncrementAsync] = -1 434 | }).to.be.ok() 435 | 436 | MockOrderedDataStore:SetAsync("TestKey3", 0) 437 | expect(Test.checkpointBudget{ 438 | [Enum.DataStoreRequestType.SetIncrementAsync] = -1 439 | }).to.be.ok() 440 | 441 | end) 442 | 443 | it("should throttle requests correctly when out of budget", function() 444 | --TODO 445 | end) 446 | 447 | it("should throttle requests to respect write cooldown", function() 448 | --TODO 449 | end) 450 | 451 | it("should throw for invalid input", function() 452 | Test.reset() 453 | Test.setStaticBudgets(100) 454 | local MockOrderedDataStore = Test.Service:GetOrderedDataStore("Test") 455 | 456 | expect(function() 457 | MockOrderedDataStore:SetAsync() 458 | end).to.throw() 459 | 460 | expect(function() 461 | MockOrderedDataStore:SetAsync(nil, 42) 462 | end).to.throw() 463 | 464 | expect(function() 465 | MockOrderedDataStore:SetAsync(123, 42) 466 | end).to.throw() 467 | 468 | expect(function() 469 | MockOrderedDataStore:SetAsync("", 42) 470 | end).to.throw() 471 | 472 | expect(function() 473 | MockOrderedDataStore:SetAsync(("a"):rep(Test.Constants.MAX_LENGTH_KEY + 1), 42) 474 | end).to.throw() 475 | 476 | end) 477 | 478 | it("should throw at attempts to store invalid data", function() 479 | Test.reset() 480 | Test.setStaticBudgets(100) 481 | local MockOrderedDataStore = Test.Service:GetOrderedDataStore("Test") 482 | 483 | local function testValue(v) 484 | expect(function() 485 | MockOrderedDataStore:SetAsync("TestKey", v) 486 | end).to.throw() 487 | end 488 | 489 | testValue(nil) 490 | testValue("string") 491 | testValue({}) 492 | testValue(true) 493 | testValue(function() end) 494 | testValue(Instance.new("Frame")) 495 | testValue(Enum.DataStoreRequestType.GetAsync) 496 | testValue(coroutine.create(function() end)) 497 | testValue(10.23) 498 | testValue(math.huge) 499 | testValue(-math.huge) 500 | testValue(-294.4) 501 | 502 | end) 503 | 504 | it("should not set the get-cache", function() 505 | Test.reset() 506 | Test.setStaticBudgets(100) 507 | local MockOrderedDataStore = Test.Service:GetOrderedDataStore("Test") 508 | 509 | MockOrderedDataStore:SetAsync("TestKey", 1) 510 | 511 | Test.captureBudget() 512 | 513 | MockOrderedDataStore:GetAsync("TestKey") 514 | 515 | expect(Test.checkpointBudget{ 516 | [Enum.DataStoreRequestType.GetAsync] = -1; 517 | }).to.equal(true) 518 | 519 | end) 520 | 521 | end) 522 | 523 | describe("MockOrderedDataStore::UpdateAsync", function() 524 | 525 | it("should update keys correctly", function() 526 | Test.reset() 527 | Test.setStaticBudgets(100) 528 | local MockOrderedDataStore = Test.Service:GetOrderedDataStore("Test") 529 | 530 | MockOrderedDataStore:ImportFromJSON({TestKey2 = 42}) 531 | 532 | MockOrderedDataStore:UpdateAsync("TestKey1", function() return 123 end) 533 | MockOrderedDataStore:UpdateAsync("TestKey2", function() return 456 end) 534 | 535 | local exported = HttpService:JSONDecode(MockOrderedDataStore:ExportToJSON()) 536 | expect(exported.TestKey1).to.equal(123) 537 | expect(exported.TestKey2).to.equal(456) 538 | 539 | end) 540 | 541 | it("should return the updated value", function() 542 | Test.reset() 543 | Test.setStaticBudgets(100) 544 | local MockOrderedDataStore = Test.Service:GetOrderedDataStore("Test") 545 | 546 | expect(MockOrderedDataStore:UpdateAsync("TestKey", function() return 13 end)).to.equal(13) 547 | 548 | end) 549 | 550 | it("should pass the old value to the callback", function() 551 | Test.reset() 552 | Test.setStaticBudgets(100) 553 | local MockOrderedDataStore = Test.Service:GetOrderedDataStore("Test") 554 | 555 | local oldValues = { 556 | TestKey1 = 0; 557 | TestKey2 = 100; 558 | TestKey3 = -100; 559 | } 560 | 561 | MockOrderedDataStore:ImportFromJSON(oldValues) 562 | 563 | for key, value in pairs(oldValues) do 564 | expect(MockOrderedDataStore:UpdateAsync(key, function(oldValue) 565 | if oldValue == value then 566 | return oldValue 567 | end 568 | error() 569 | end)).never.to.throw() 570 | end 571 | 572 | end) 573 | 574 | it("should consume budgets correctly", function() 575 | Test.reset() 576 | Test.setStaticBudgets(100) 577 | local MockOrderedDataStore = Test.Service:GetOrderedDataStore("Test") 578 | 579 | Test.captureBudget() 580 | 581 | for i = 1, 10 do 582 | MockOrderedDataStore:UpdateAsync("TestKey"..i, function() return 1 end) 583 | expect(Test.checkpointBudget{ 584 | [Enum.DataStoreRequestType.GetAsync] = -1; 585 | [Enum.DataStoreRequestType.SetIncrementAsync] = -1; 586 | }).to.be.ok() 587 | end 588 | 589 | end) 590 | 591 | it("should throttle requests correctly when out of budget", function() 592 | --TODO 593 | end) 594 | 595 | it("should throttle requests to respect write cooldown", function() 596 | --TODO 597 | end) 598 | 599 | it("should throw for invalid key", function() 600 | Test.reset() 601 | Test.setStaticBudgets(100) 602 | local MockOrderedDataStore = Test.Service:GetOrderedDataStore("Test") 603 | 604 | local func = function() return 1 end 605 | 606 | expect(function() 607 | MockOrderedDataStore:UpdateAsync() 608 | end).to.throw() 609 | 610 | expect(function() 611 | MockOrderedDataStore:UpdateAsync(nil, func) 612 | end).to.throw() 613 | 614 | expect(function() 615 | MockOrderedDataStore:UpdateAsync(123, func) 616 | end).to.throw() 617 | 618 | expect(function() 619 | MockOrderedDataStore:UpdateAsync("", func) 620 | end).to.throw() 621 | 622 | expect(function() 623 | MockOrderedDataStore:UpdateAsync(("a"):rep(Test.Constants.MAX_LENGTH_KEY + 1), func) 624 | end).to.throw() 625 | 626 | end) 627 | 628 | it("should throw at attempts to store invalid data", function() 629 | Test.reset() 630 | Test.setStaticBudgets(100) 631 | local MockOrderedDataStore = Test.Service:GetOrderedDataStore("Test") 632 | 633 | local function testValue(v) 634 | expect(function() 635 | MockOrderedDataStore:UpdateAsync("TestKey", function() return v end) 636 | end).to.throw() 637 | end 638 | 639 | testValue(nil) 640 | testValue("string") 641 | testValue({}) 642 | testValue(true) 643 | testValue(function() end) 644 | testValue(Instance.new("Frame")) 645 | testValue(Enum.DataStoreRequestType.GetAsync) 646 | testValue(coroutine.create(function() end)) 647 | testValue(10.23) 648 | testValue(math.huge) 649 | testValue(-math.huge) 650 | testValue(-294.4) 651 | 652 | end) 653 | 654 | it("should set the get-cache", function() 655 | Test.reset() 656 | Test.setStaticBudgets(100) 657 | local MockOrderedDataStore = Test.Service:GetOrderedDataStore("Test") 658 | 659 | Test.captureBudget() 660 | 661 | MockOrderedDataStore:UpdateAsync("TestKey", function() return 1 end) 662 | MockOrderedDataStore:GetAsync("TestKey") 663 | 664 | expect(Test.checkpointBudget{ 665 | [Enum.DataStoreRequestType.GetAsync] = -1; 666 | [Enum.DataStoreRequestType.SetIncrementAsync] = -1; 667 | [Enum.DataStoreRequestType.UpdateAsync] = -1; 668 | }).to.equal(true) 669 | 670 | end) 671 | 672 | end) 673 | 674 | describe("MockOrderedDataStore::OnUpdate", function() 675 | 676 | it("should return a RBXScriptConnection", function() 677 | Test.reset() 678 | Test.setStaticBudgets(100) 679 | local MockOrderedDataStore = Test.Service:GetOrderedDataStore("Test") 680 | 681 | local conn = MockOrderedDataStore:OnUpdate("TestKey") 682 | 683 | conn:Disconnect() -- don't leak after test 684 | 685 | expect(conn).to.be.a("RBXScriptConnection") 686 | 687 | end) 688 | 689 | it("should only receives updates for its connected key", function() 690 | --TODO 691 | end) 692 | 693 | it("should work with SetAsync", function() 694 | --TODO 695 | end) 696 | 697 | it("should work with UpdateAsync", function() 698 | --TODO 699 | end) 700 | 701 | it("should work with RemoveAsync", function() 702 | --TODO 703 | end) 704 | 705 | it("should work with IncrementAsync", function() 706 | --TODO 707 | end) 708 | 709 | it("should consume budgets correctly", function() 710 | Test.reset() 711 | Test.setStaticBudgets(100) 712 | local MockOrderedDataStore = Test.Service:GetOrderedDataStore("Test") 713 | 714 | Test.captureBudget() 715 | 716 | for i = 1, 10 do 717 | local conn = MockOrderedDataStore:OnUpdate("TestKey"..i, function() end) 718 | conn:Disconnect() 719 | expect(Test.checkpointBudget{ 720 | [Enum.DataStoreRequestType.OnUpdate] = -1; 721 | }).to.be.ok() 722 | end 723 | 724 | end) 725 | 726 | it("should throttle requests correctly when out of budget", function() 727 | --TODO 728 | end) 729 | 730 | it("should throw for invalid input", function() 731 | Test.reset() 732 | Test.setStaticBudgets(100) 733 | local MockOrderedDataStore = Test.Service:GetOrderedDataStore("Test") 734 | 735 | expect(function() 736 | MockOrderedDataStore:OnUpdate() 737 | end).to.throw() 738 | 739 | expect(function() 740 | MockOrderedDataStore:OnUpdate(123) 741 | end).to.throw() 742 | 743 | expect(function() 744 | MockOrderedDataStore:OnUpdate("") 745 | end).to.throw() 746 | 747 | expect(function() 748 | MockOrderedDataStore:OnUpdate(("a"):rep(Test.Constants.MAX_LENGTH_KEY + 1)) 749 | end).to.throw() 750 | 751 | expect(function() 752 | MockOrderedDataStore:OnUpdate("Test", 123) 753 | end).to.throw() 754 | 755 | expect(function() 756 | MockOrderedDataStore:OnUpdate(123, function() end) 757 | end).to.throw() 758 | 759 | end) 760 | 761 | it("should not set the get-cache", function() 762 | Test.reset() 763 | Test.setStaticBudgets(100) 764 | local MockOrderedDataStore = Test.Service:GetOrderedDataStore("Test") 765 | 766 | local connection = MockOrderedDataStore:OnUpdate("TestKey", function() end) 767 | 768 | Test.captureBudget() 769 | 770 | local result = expect(function() 771 | MockOrderedDataStore:GetAsync("TestKey") 772 | end) 773 | 774 | if connection then 775 | connection:Disconnect() 776 | end 777 | 778 | expect(result.never.to.throw()) 779 | expect(Test.checkpointBudget{ 780 | [Enum.DataStoreRequestType.GetAsync] = -1; 781 | }).to.be.ok() 782 | 783 | end) 784 | 785 | end) 786 | 787 | describe("MockOrderedDataStore::GetSortedAsync", function() 788 | 789 | it("should complete successfully and return object for valid input", function() 790 | Test.reset() 791 | Test.setStaticBudgets(100) 792 | local MockOrderedDataStore = Test.Service:GetOrderedDataStore("Test") 793 | 794 | expect(MockOrderedDataStore:GetSortedAsync(true, 50)).to.be.ok() 795 | expect(MockOrderedDataStore:GetSortedAsync(true, 50, 100)).to.be.ok() 796 | expect(MockOrderedDataStore:GetSortedAsync(true, 50, nil, 100)).to.be.ok() 797 | expect(MockOrderedDataStore:GetSortedAsync(true, 50, 100, 200)).to.be.ok() 798 | 799 | expect(MockOrderedDataStore:GetSortedAsync(false, 50)).to.be.ok() 800 | expect(MockOrderedDataStore:GetSortedAsync(false, 50, 100)).to.be.ok() 801 | expect(MockOrderedDataStore:GetSortedAsync(false, 50, nil, 100)).to.be.ok() 802 | expect(MockOrderedDataStore:GetSortedAsync(false, 50, 100, 200)).to.be.ok() 803 | 804 | end) 805 | 806 | it("should consume budgets correctly", function() 807 | Test.reset() 808 | Test.setStaticBudgets(100) 809 | local MockOrderedDataStore = Test.Service:GetOrderedDataStore("Test") 810 | 811 | Test.captureBudget() 812 | 813 | MockOrderedDataStore:GetSortedAsync(true, 100) -- empty results 814 | expect(Test.checkpointBudget{ 815 | [Enum.DataStoreRequestType.GetSortedAsync] = -1; 816 | }).to.be.ok() 817 | 818 | local values = {} 819 | for i = 1, 789 do 820 | values["TestKey"..i] = i 821 | end 822 | 823 | MockOrderedDataStore:ImportFromJSON(values) 824 | 825 | MockOrderedDataStore:GetSortedAsync(false, 110, 230, 720) -- not empty 826 | expect(Test.checkpointBudget{ 827 | [Enum.DataStoreRequestType.GetSortedAsync] = -1; 828 | }).to.be.ok() 829 | 830 | end) 831 | 832 | it("should throttle requests correctly when out of budget", function() 833 | --TODO 834 | end) 835 | 836 | it("should throw for invalid input", function() 837 | Test.reset() 838 | Test.setStaticBudgets(100) 839 | local MockOrderedDataStore = Test.Service:GetOrderedDataStore("Test") 840 | 841 | expect(function() 842 | MockOrderedDataStore:GetSortedAsync() 843 | end).to.throw() 844 | 845 | expect(function() 846 | MockOrderedDataStore:GetSortedAsync("wrong type", 50) 847 | end).to.throw() 848 | 849 | expect(function() 850 | MockOrderedDataStore:GetSortedAsync(true) 851 | end).to.throw() 852 | 853 | expect(function() 854 | MockOrderedDataStore:GetSortedAsync(false, "wrong type") 855 | end).to.throw() 856 | 857 | expect(function() 858 | MockOrderedDataStore:GetSortedAsync(true, -5) 859 | end).to.throw() 860 | 861 | expect(function() 862 | MockOrderedDataStore:GetSortedAsync(false, 0) 863 | end).to.throw() 864 | 865 | expect(function() 866 | MockOrderedDataStore:GetSortedAsync(true, Test.Constants.MAX_PAGE_SIZE + 1) 867 | end).to.throw() 868 | 869 | expect(function() 870 | MockOrderedDataStore:GetSortedAsync(false, 56.7) 871 | end).to.throw() 872 | 873 | expect(function() 874 | MockOrderedDataStore:GetSortedAsync(false, 50, "wrong type") 875 | end).to.throw() 876 | 877 | expect(function() 878 | MockOrderedDataStore:GetSortedAsync(false, 50, 102.9) 879 | end).to.throw() 880 | 881 | expect(function() 882 | MockOrderedDataStore:GetSortedAsync(true, 50, 100, "wrong type") 883 | end).to.throw() 884 | 885 | expect(function() 886 | MockOrderedDataStore:GetSortedAsync(true, 50, 100, 472.39) 887 | end).to.throw() 888 | 889 | expect(function() 890 | MockOrderedDataStore:GetSortedAsync(true, 50, 100, 99) 891 | end).to.throw() 892 | 893 | end) 894 | 895 | -- See more testing with ordered data lookups in MockDataStorePages.spec.lua! 896 | 897 | end) 898 | 899 | describe("MockOrderedDataStore::ImportFromJSON/ExportToJSON", function() 900 | 901 | it("should import keys correctly", function() 902 | Test.reset() 903 | local MockOrderedDataStore = Test.Service:GetOrderedDataStore("Test", "Test") 904 | 905 | local scope = { 906 | TestKey1 = 1; 907 | TestKey2 = 2; 908 | TestKey3 = 3; 909 | } 910 | 911 | expect(function() 912 | MockOrderedDataStore:ImportFromJSON(scope, false) 913 | MockOrderedDataStore:ImportFromJSON(HttpService:JSONEncode(scope), false) 914 | end).never.to.throw() 915 | 916 | end) 917 | 918 | it("should contain all imported values afterwards", function() 919 | Test.reset() 920 | local MockOrderedDataStore = Test.Service:GetOrderedDataStore("Test", "Test") 921 | 922 | local data = {} 923 | for i = 1, 100 do 924 | data["TestKey"..i] = i 925 | end 926 | 927 | MockOrderedDataStore:ImportFromJSON(data, false) 928 | 929 | local exported = HttpService:JSONDecode(MockOrderedDataStore:ExportToJSON()) 930 | for i = 1, 100 do 931 | expect(exported["TestKey"..i]).to.equal(i) 932 | end 933 | 934 | end) 935 | 936 | it("should fire OnUpdate signals", function() 937 | --TODO 938 | end) 939 | 940 | it("should ignore invalid values and keys", function() 941 | Test.reset() 942 | local MockOrderedDataStore = Test.Service:GetOrderedDataStore("Test", "Test") 943 | 944 | local data = { 945 | TestKey1 = 1; 946 | TestKey2 = true; 947 | TestKey3 = "Test"; 948 | TestKey4 = {1,2,3,4}; 949 | TestKey5 = 5; 950 | TestKey6 = 6; 951 | [true] = 7; 952 | TestKey8 = Instance.new("Frame"); 953 | TestKey9 = math.huge; 954 | TestKey10 = -math.huge; 955 | TestKey11 = 11; 956 | } 957 | 958 | MockOrderedDataStore:ImportFromJSON(data, false) 959 | 960 | local store = HttpService:JSONDecode(MockOrderedDataStore:ExportToJSON()) 961 | expect(store.TestKey1).to.equal(1) 962 | expect(store.TestKey2).to.never.be.ok() 963 | expect(store.TestKey3).to.never.be.ok() 964 | expect(store.TestKey4).to.never.be.ok() 965 | expect(store.TestKey5).to.equal(5) 966 | expect(store.TestKey6).to.equal(6) 967 | expect(store[true]).to.never.be.ok() 968 | expect(store.TestKey8).to.never.be.ok() 969 | expect(store.TestKey9).to.never.be.ok() 970 | expect(store.TestKey10).to.never.be.ok() 971 | expect(store.TestKey11).to.equal(11) 972 | 973 | end) 974 | 975 | it("should throw for invalid input", function() 976 | Test.reset() 977 | local MockOrderedDataStore = Test.Service:GetOrderedDataStore("Test", "Test") 978 | 979 | expect(function() 980 | MockOrderedDataStore:ImportFromJSON("{this is invalid json}", false) 981 | end).to.throw() 982 | 983 | expect(function() 984 | MockOrderedDataStore:ImportFromJSON(123, false) 985 | end).to.throw() 986 | 987 | expect(function() 988 | MockOrderedDataStore:ImportFromJSON({}, 123) 989 | end).to.throw() 990 | 991 | expect(function() 992 | MockOrderedDataStore:ImportFromJSON("{}", 123) 993 | end).to.throw() 994 | 995 | end) 996 | 997 | end) 998 | 999 | end 1000 | -------------------------------------------------------------------------------- /spec/MockDataStoreService/Test.lua: -------------------------------------------------------------------------------- 1 | local Test = {} 2 | 3 | local MockDataStoreService_Module = script.Parent.Parent.Parent.DataStoreService.MockDataStoreService 4 | 5 | Test.Service = require(MockDataStoreService_Module) 6 | Test.Constants = require(MockDataStoreService_Module.MockDataStoreConstants) 7 | Test.Manager = require(MockDataStoreService_Module.MockDataStoreManager) 8 | Test.Utils = require(MockDataStoreService_Module.MockDataStoreUtils) 9 | Test.Pages = require(MockDataStoreService_Module.MockDataStorePages) 10 | 11 | Test.Constants.YIELD_TIME_MIN = 0 12 | Test.Constants.YIELD_TIME_MAX = 0 13 | Test.Constants.YIELD_TIME_UPDATE_MIN = 0 14 | Test.Constants.YIELD_TIME_UPDATE_MAX = 0 15 | 16 | local capturedBudgets = {} 17 | 18 | function Test.reset() 19 | Test.Manager.ResetData() 20 | Test.Manager.ResetBudget() 21 | Test.Manager.ThawBudgetUpdates() 22 | capturedBudgets = {} 23 | end 24 | 25 | function Test.subsetOf(t1, t2) 26 | if type(t1) ~= "table" or type(t2) ~= "table" then 27 | return t1 == t2 28 | end 29 | for key, value in pairs(t1) do 30 | if type(value) == "table" then 31 | if type(t2[key]) == "table" then 32 | if not Test.subsetOf(t1[key], t2[key]) then 33 | return false 34 | end 35 | else 36 | return false 37 | end 38 | elseif t1[key] ~= t2[key] then 39 | return false 40 | end 41 | end 42 | return true 43 | end 44 | 45 | function Test.setStaticBudgets(var) 46 | Test.Manager.FreezeBudgetUpdates() 47 | if type(var) == "number" then 48 | local budget = var 49 | for _,v in pairs(Enum.DataStoreRequestType:GetEnumItems()) do 50 | Test.Manager.SetBudget(v, budget) 51 | end 52 | elseif type(var) == "table" then 53 | local budgets = var 54 | for requestType, budget in pairs(budgets) do 55 | Test.Manager.SetBudget(requestType, budget) 56 | end 57 | end 58 | end 59 | 60 | function Test.captureBudget() 61 | for _,v in pairs(Enum.DataStoreRequestType:GetEnumItems()) do 62 | if v ~= Enum.DataStoreRequestType.UpdateAsync then 63 | capturedBudgets[v] = Test.Manager.GetBudget(v) 64 | end 65 | end 66 | end 67 | 68 | function Test.checkpointBudget(checkpoint) 69 | local match = true 70 | for requestType, difference in pairs(checkpoint) do 71 | if Test.Manager.GetBudget(requestType) - capturedBudgets[requestType] ~= difference then 72 | match = nil 73 | break 74 | end 75 | capturedBudgets[requestType] = nil 76 | end 77 | if match then 78 | for requestType, budget in pairs(capturedBudgets) do 79 | if Test.Manager.GetBudget(requestType) ~= budget then 80 | match = nil 81 | end 82 | end 83 | end 84 | Test.captureBudget() 85 | return match 86 | end 87 | 88 | return Test 89 | -------------------------------------------------------------------------------- /spec/MockDataStoreService/init.spec.lua: -------------------------------------------------------------------------------- 1 | return function() 2 | local Test = require(script.Parent.Test) 3 | local HttpService = game:GetService("HttpService") 4 | 5 | local function reset() 6 | Test.Manager.ResetData() 7 | Test.Manager.ResetBudget() 8 | Test.Manager.ThawBudgetUpdates() 9 | end 10 | 11 | describe("MockDataStoreService", function() 12 | 13 | it("should expose all API members", function() 14 | expect(Test.Service.GetDataStore).to.be.a("function") 15 | expect(Test.Service.GetGlobalDataStore).to.be.a("function") 16 | expect(Test.Service.GetOrderedDataStore).to.be.a("function") 17 | expect(Test.Service.GetRequestBudgetForRequestType).to.be.a("function") 18 | expect(Test.Service.ImportFromJSON).to.be.a("function") 19 | expect(Test.Service.ExportFromJSON).to.be.a("function") 20 | end) 21 | 22 | end) 23 | 24 | describe("Test.Service::GetDataStore", function() 25 | 26 | it("should return an object for valid input", function() 27 | expect(Test.Service:GetDataStore("Test")).to.be.ok() 28 | expect(Test.Service:GetDataStore("Test2", "Test2")).to.be.ok() 29 | end) 30 | 31 | it("should throw for invalid input", function() 32 | 33 | expect(function() 34 | Test.Service:GetDataStore() 35 | end).to.throw() 36 | 37 | expect(function() 38 | Test.Service:GetDataStore(nil, "Test") 39 | end).to.throw() 40 | 41 | expect(function() 42 | Test.Service:GetDataStore("Test", 123) 43 | end).to.throw() 44 | 45 | expect(function() 46 | Test.Service:GetDataStore(("a"):rep(Test.Constants.MAX_LENGTH_NAME + 1), "Test") 47 | end).to.throw() 48 | 49 | expect(function() 50 | Test.Service:GetDataStore(123, "Test") 51 | end).to.throw() 52 | 53 | expect(function() 54 | Test.Service:GetDataStore("Test", ("a"):rep(Test.Constants.MAX_LENGTH_SCOPE + 1)) 55 | end).to.throw() 56 | 57 | expect(function() 58 | Test.Service:GetDataStore("", "Test") 59 | end).to.throw() 60 | 61 | expect(function() 62 | Test.Service:GetDataStore("Test", "") 63 | end).to.throw() 64 | 65 | end) 66 | 67 | end) 68 | 69 | describe("Test.Service::GetGlobalDataStore", function() 70 | 71 | it("should return an object", function() 72 | expect(Test.Service:GetGlobalDataStore()).to.be.ok() 73 | end) 74 | 75 | end) 76 | 77 | describe("Test.Service::GetOrderedDataStore", function() 78 | 79 | it("should return an object for valid input", function() 80 | expect(Test.Service:GetOrderedDataStore("Test")).to.be.ok() 81 | expect(Test.Service:GetOrderedDataStore("Test2", "Test2")).to.be.ok() 82 | end) 83 | 84 | it("should throw for invalid input", function() 85 | 86 | expect(function() 87 | Test.Service:GetOrderedDataStore() 88 | end).to.throw() 89 | 90 | expect(function() 91 | Test.Service:GetOrderedDataStore(nil, "Test") 92 | end).to.throw() 93 | 94 | expect(function() 95 | Test.Service:GetOrderedDataStore("Test", 123) 96 | end).to.throw() 97 | 98 | expect(function() 99 | Test.Service:GetOrderedDataStore(("a"):rep(51), "Test") 100 | end).to.throw() 101 | 102 | expect(function() 103 | Test.Service:GetOrderedDataStore(123, "Test") 104 | end).to.throw() 105 | 106 | expect(function() 107 | Test.Service:GetOrderedDataStore("Test", ("a"):rep(51)) 108 | end).to.throw() 109 | 110 | expect(function() 111 | Test.Service:GetOrderedDataStore("", "Test") 112 | end).to.throw() 113 | 114 | expect(function() 115 | Test.Service:GetOrderedDataStore("Test", "") 116 | end).to.throw() 117 | 118 | end) 119 | 120 | end) 121 | 122 | describe("Test.Service::GetRequestBudgetForRequestType", function() 123 | 124 | it("should return numerical budgets", function() 125 | for _,v in pairs(Enum.DataStoreRequestType:GetEnumItems()) do 126 | expect(Test.Service:GetRequestBudgetForRequestType(v)).to.be.a("number") 127 | end 128 | end) 129 | 130 | it("should accept enumerator values", function() 131 | for _,v in pairs(Enum.DataStoreRequestType:GetEnumItems()) do 132 | expect(Test.Service:GetRequestBudgetForRequestType(v.Value)).to.be.ok() 133 | end 134 | end) 135 | 136 | it("should accept enumerator names", function() 137 | for _,v in pairs(Enum.DataStoreRequestType:GetEnumItems()) do 138 | expect(Test.Service:GetRequestBudgetForRequestType(v.Name)).to.be.ok() 139 | end 140 | end) 141 | 142 | it("should throw for invalid input", function() 143 | 144 | expect(function() 145 | Test.Service:GetRequestBudgetForRequestType("NotARequestType") 146 | end).to.throw() 147 | 148 | expect(function() 149 | Test.Service:GetRequestBudgetForRequestType() 150 | end).to.throw() 151 | 152 | expect(function() 153 | Test.Service:GetRequestBudgetForRequestType(13373) 154 | end).to.throw() 155 | 156 | expect(function() 157 | Test.Service:GetRequestBudgetForRequestType(true) 158 | end).to.throw() 159 | 160 | end) 161 | 162 | end) 163 | 164 | local testDataStores = { 165 | DataStores = { 166 | ImportTestName = { 167 | ImportTestScope = { 168 | TestKey1 = true; 169 | TestKey2 = "Hello world!"; 170 | TestKey3 = {First = 1, Second = 2, Third = 3}; 171 | TestKey4 = false; 172 | }; 173 | ImportTestScope2 = {}; 174 | ImportTestScope3 = { 175 | TestKey1 = "Test string"; 176 | TestKey2 = { 177 | First = {First = "Hello"}; 178 | Second = {First = true, Second = false}; 179 | Third = 3; 180 | Fourth = {"One", 1, "Two", 2, "Three", {3, 4, 5, 6}, 7}; 181 | }; 182 | TestKey3 = 12345; 183 | }; 184 | }; 185 | ImportTestName2 = {}; 186 | }; 187 | OrderedDataStores = { 188 | ImportTestName = { 189 | ImportTestScope = { 190 | TestKey1 = 1; 191 | TestKey2 = 2; 192 | TestKey3 = 3; 193 | }; 194 | ImportTestScope2 = { 195 | TestKey1 = 100; 196 | TestKey2 = 12308; 197 | TestKey3 = 1288; 198 | TestKey4 = 1287; 199 | }; 200 | ImportTestScope3 = {}; 201 | }; 202 | ImportTestName2 = { 203 | ImportTestScope = {}; 204 | }; 205 | ImportTestName3 = {}; 206 | }; 207 | GlobalDataStore = { 208 | TestImportKey1 = -5.1; 209 | TestImportKey2 = "Test string"; 210 | TestImportKey3 = {}; 211 | }; 212 | } 213 | 214 | describe("Test.Service::ImportFromJSON", function() 215 | 216 | it("should import from correct json strings", function() 217 | reset() 218 | 219 | local json = HttpService:JSONEncode(testDataStores) 220 | 221 | expect(function() 222 | Test.Service:ImportFromJSON(json, false) 223 | end).never.to.throw() 224 | 225 | end) 226 | 227 | it("should import from correct table input", function() 228 | reset() 229 | 230 | expect(function() 231 | Test.Service:ImportFromJSON(testDataStores, false) 232 | end).never.to.throw() 233 | 234 | end) 235 | 236 | it("should contain newly imported values after importing", function() 237 | reset() 238 | 239 | Test.Service:ImportFromJSON(testDataStores, false) 240 | 241 | local globalData = Test.Manager.GetGlobalData() 242 | expect(globalData).to.be.ok() 243 | expect(Test.subsetOf(globalData, testDataStores.GlobalDataStore)).to.equal(true) 244 | 245 | for name, scopes in pairs(testDataStores.DataStores) do 246 | for scope, data in pairs(scopes) do 247 | local importedData = Test.Manager.GetData(name, scope) 248 | expect(importedData).to.be.ok() 249 | expect(Test.subsetOf(data, importedData)).to.equal(true) 250 | end 251 | end 252 | 253 | for name, scopes in pairs(testDataStores.OrderedDataStores) do 254 | for scope, data in pairs(scopes) do 255 | local importedData = Test.Manager.GetOrderedData(name, scope) 256 | expect(importedData).to.be.ok() 257 | expect(Test.subsetOf(data, importedData)).to.equal(true) 258 | end 259 | end 260 | 261 | end) 262 | 263 | it("should contain old values after importing new values", function() 264 | reset() 265 | 266 | local oldValues = { 267 | DataStores = { 268 | ImportTestName = { 269 | ImportTestScope = { 270 | TestKey5 = 123; 271 | TestKey6 = {A = "a", B = "b", C = "c"}; 272 | }; 273 | ImportTestScope2 = { 274 | TestKey1 = "Test"; 275 | }; 276 | ImportTestScope4 = { 277 | TestKey1 = "Hello world!"; 278 | } 279 | }; 280 | ImportTestName2 = { 281 | ImportTestScope = { 282 | TestKey1 = 456; 283 | TestKey2 = {1,2,3,4}; 284 | }; 285 | }; 286 | }; 287 | GlobalDataStore = { 288 | TestImportKey4 = "Foo"; 289 | TestImportKey5 = "Bar"; 290 | TestImportKey6 = "Baz"; 291 | }; 292 | } 293 | 294 | Test.Service:ImportFromJSON(oldValues, false) 295 | 296 | Test.Service:ImportFromJSON(testDataStores, false) 297 | 298 | local globalData = Test.Manager.GetGlobalData() 299 | expect(globalData).to.be.ok() 300 | expect(Test.subsetOf(globalData, oldValues.GlobalDataStore)).to.equal(true) 301 | 302 | for name, scopes in pairs(oldValues.DataStores) do 303 | for scope, data in pairs(scopes) do 304 | local importedData = Test.Manager.GetData(name, scope) 305 | expect(importedData).to.be.ok() 306 | expect(Test.subsetOf(data, importedData)).to.equal(true) 307 | end 308 | end 309 | 310 | end) 311 | 312 | it("should not contain invalid entries from input tables after importing", function() 313 | reset() 314 | 315 | local frame = Instance.new("Frame") 316 | local func = function() end 317 | 318 | local partiallyValid = { 319 | DataStores = { 320 | ImportTestName = { 321 | ImportTestScope = { 322 | TestKey1 = 123; 323 | TestKey2 = {A = "a", "b", C = "c"}; -- mixed table 324 | }; 325 | ImportTestScope2 = { 326 | TestKey1 = func; -- invalid type 327 | TestKey2 = "Hello world!"; 328 | TestKey3 = frame; 329 | }; 330 | ImportTestScope3 = { 331 | TestKey1 = "Hello world!"; 332 | [frame] = 123; -- invalid keys 333 | [true] = 456; 334 | [123] = 789; 335 | [func] = "abc"; 336 | }; 337 | }; 338 | ImportTestName2 = { 339 | ImportTestScope = { 340 | TestKey1 = 456; 341 | TestKey2 = {1,2,3,4}; 342 | TestKey3 = {[0] = 1, 2, 3}; -- does not start at 1 343 | }; 344 | ImportTestScope2 = { 345 | TestKey1 = {[1] = 1, [2] = 2, [4] = 3}; -- holes 346 | TestKey2 = {[1.2] = true}; -- invalid key entry 347 | }; 348 | }; 349 | }; 350 | GlobalDataStore = { 351 | TestKey1 = "Foo"; 352 | TestKey2 = "Bar"; 353 | TestKey3 = "Baz"; 354 | TestKey4 = math.huge; -- invalid value 355 | }; 356 | } 357 | 358 | Test.Service:ImportFromJSON(partiallyValid, false) 359 | 360 | expect(Test.Manager.GetData("ImportTestName", "ImportTestScope").TestKey1).to.be.ok() 361 | expect(Test.Manager.GetData("ImportTestName", "ImportTestScope").TestKey2).to.never.be.ok() 362 | 363 | expect(Test.Manager.GetData("ImportTestName", "ImportTestScope2").TestKey1).to.never.be.ok() 364 | expect(Test.Manager.GetData("ImportTestName", "ImportTestScope2").TestKey2).to.be.ok() 365 | expect(Test.Manager.GetData("ImportTestName", "ImportTestScope2").TestKey3).to.never.be.ok() 366 | 367 | expect(Test.Manager.GetData("ImportTestName", "ImportTestScope3").TestKey1).to.be.ok() 368 | expect(Test.Manager.GetData("ImportTestName", "ImportTestScope3")[frame]).to.never.be.ok() 369 | expect(Test.Manager.GetData("ImportTestName", "ImportTestScope3")[true]).to.never.be.ok() 370 | expect(Test.Manager.GetData("ImportTestName", "ImportTestScope3")[123]).to.never.be.ok() 371 | expect(Test.Manager.GetData("ImportTestName", "ImportTestScope3")[func]).to.never.be.ok() 372 | 373 | expect(Test.Manager.GetData("ImportTestName2", "ImportTestScope").TestKey1).to.be.ok() 374 | expect(Test.Manager.GetData("ImportTestName2", "ImportTestScope").TestKey2).to.be.ok() 375 | expect(Test.Manager.GetData("ImportTestName2", "ImportTestScope").TestKey3).to.never.be.ok() 376 | 377 | expect(Test.Manager.GetData("ImportTestName2", "ImportTestScope2").TestKey1).to.never.be.ok() 378 | expect(Test.Manager.GetData("ImportTestName2", "ImportTestScope2").TestKey2).to.never.be.ok() 379 | 380 | expect(Test.Manager.GetGlobalData().TestKey1).to.be.ok() 381 | expect(Test.Manager.GetGlobalData().TestKey1).to.be.ok() 382 | expect(Test.Manager.GetGlobalData().TestKey1).to.be.ok() 383 | expect(Test.Manager.GetGlobalData().TestKey1).to.never.be.ok() 384 | 385 | end) 386 | 387 | it("should throw for invalid input", function() 388 | 389 | expect(function() 390 | Test.Service:ImportFromJSON("{this is invalid json}", false) 391 | end).to.throw() 392 | 393 | expect(function() 394 | Test.Service:ImportFromJSON(123, false) 395 | end).to.throw() 396 | 397 | expect(function() 398 | Test.Service:ImportFromJSON({}, 123) 399 | end).to.throw() 400 | 401 | expect(function() 402 | Test.Service:ImportFromJSON("{}", 123) 403 | end).to.throw() 404 | 405 | end) 406 | 407 | end) 408 | 409 | describe("Test.Service::ExportToJSON", function() 410 | 411 | it("should return valid json", function() 412 | reset() 413 | 414 | Test.Service:ImportFromJSON(testDataStores, false) 415 | 416 | local json = Test.Service:ExportToJSON() 417 | 418 | expect(function() 419 | HttpService:JSONDecode(json) 420 | end).never.to.throw() 421 | 422 | end) 423 | 424 | it("should export all values", function() 425 | reset() 426 | 427 | Test.Service:ImportFromJSON(testDataStores, false) 428 | 429 | local exported = HttpService:JSONDecode(Test.Service:ExportToJSON()) 430 | 431 | expect(Test.subsetOf(exported, testDataStores)).to.equal(true) 432 | 433 | end) 434 | 435 | it("should not contain empty datastore scopes", function() 436 | reset() 437 | 438 | Test.Service:ImportFromJSON(testDataStores, false) 439 | 440 | local exported = HttpService:JSONDecode(Test.Service:ExportToJSON()) 441 | 442 | expect(exported.DataStores.ImportTestName.ImportTestScope2).to.never.be.ok() 443 | expect(exported.OrderedDataStores.ImportTestName.ImportTestScope3).to.never.be.ok() 444 | expect(exported.OrderedDataStores.ImportTestName2.ImportTestScope).to.never.be.ok() 445 | 446 | end) 447 | 448 | it("should not contain empty datastore names", function() 449 | reset() 450 | 451 | Test.Service:ImportFromJSON(testDataStores, false) 452 | 453 | local exported = HttpService:JSONDecode(Test.Service:ExportToJSON()) 454 | 455 | expect(exported.DataStores.ImportTestName2).to.never.be.ok() 456 | expect(exported.OrderedDataStores.ImportTestName3).to.never.be.ok() 457 | 458 | end) 459 | 460 | it("should not contain empty datastore types", function() 461 | reset() 462 | 463 | local exported = HttpService:JSONDecode(Test.Service:ExportToJSON()) 464 | 465 | expect(exported.DataStores).to.never.be.ok() 466 | expect(exported.OrderedDataStores).to.never.be.ok() 467 | expect(exported.GlobalDataStore).to.never.be.ok() 468 | 469 | end) 470 | 471 | end) 472 | 473 | end 474 | -------------------------------------------------------------------------------- /spec/init.spec.lua: -------------------------------------------------------------------------------- 1 | return function() 2 | 3 | describe("DataStoreService", function() 4 | 5 | it("should return MockDataStoreService in a test environment", function() 6 | local DataStoreService = require(script.Parent.Parent.DataStoreService) 7 | local MockDataStoreService = require(script.Parent.Parent.DataStoreService.MockDataStoreService) 8 | 9 | expect(MockDataStoreService).to.be.ok() 10 | expect(MockDataStoreService).to.equal(DataStoreService) 11 | end) 12 | 13 | it("should return the built-in DataStoreService in a live environment", function() 14 | local DataStoreService = require(script.Parent.Parent.DataStoreService) 15 | --TODO 16 | expect(DataStoreService).to.be.ok() 17 | end) 18 | 19 | end) 20 | 21 | end 22 | -------------------------------------------------------------------------------- /wally.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "buildthomas/mockdatastoreservice" 3 | description = "Emulation of Roblox's DataStoreService for seamless offline development & testing" 4 | version = "1.0.3" 5 | authors = ["buildthomas"] 6 | realm = "shared" 7 | license = "Apache-2.0" 8 | registry = "https://github.com/UpliftGames/wally-index" 9 | --------------------------------------------------------------------------------