Merge branch 'master' into f411_new_migrations
[mediagoblin.git] / mediagoblin / tests / test_migrations.py
1 # GNU MediaGoblin -- federated, autonomous media hosting
2 # Copyright (C) 2011 Free Software Foundation, Inc
3 #
4 # This program is free software: you can redistribute it and/or modify
5 # it under the terms of the GNU Affero General Public License as published by
6 # the Free Software Foundation, either version 3 of the License, or
7 # (at your option) any later version.
8 #
9 # This program is distributed in the hope that it will be useful,
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 # GNU Affero General Public License for more details.
13 #
14 # You should have received a copy of the GNU Affero General Public License
15 # along with this program. If not, see <http://www.gnu.org/licenses/>.
16
17
18 from nose.tools import assert_raises
19 from pymongo import Connection
20
21 from mediagoblin.tests.tools import (
22 install_fixtures_simple, assert_db_meets_expected)
23 from mediagoblin.db.util import (
24 RegisterMigration, MigrationManager, ObjectId,
25 MissingCurrentMigration)
26
27 # This one will get filled with local migrations
28 TEST_MIGRATION_REGISTRY = {}
29 # this one won't get filled
30 TEST_EMPTY_MIGRATION_REGISTRY = {}
31
32 MIGRATION_DB_NAME = u'__mediagoblin_test_migrations__'
33
34
35 ######################
36 # Fake test migrations
37 ######################
38
39 @RegisterMigration(1, TEST_MIGRATION_REGISTRY)
40 def creature_add_magical_powers(database):
41 """
42 Add lists of magical powers.
43
44 This defaults to [], an empty list. Since we haven't declared any
45 magical powers, all existing monsters should
46 """
47 database['creatures'].update(
48 {'magical_powers': {'$exists': False}},
49 {'$set': {'magical_powers': []}},
50 multi=True)
51
52
53 @RegisterMigration(2, TEST_MIGRATION_REGISTRY)
54 def creature_rename_num_legs_to_num_limbs(database):
55 """
56 It turns out we want to track how many limbs a creature has, not
57 just how many legs. We don't care about the ambiguous distinction
58 between arms/legs currently.
59 """
60 # $rename not available till 1.7.2+, Debian Stable only includes
61 # 1.4.4... we should do renames manually for now :(
62
63 collection = database['creatures']
64 target = collection.find(
65 {'num_legs': {'$exists': True}})
66
67 for document in target:
68 # A lame manual renaming.
69 document['num_limbs'] = document.pop('num_legs')
70 collection.save(document)
71
72
73 @RegisterMigration(3, TEST_MIGRATION_REGISTRY)
74 def creature_remove_is_demon(database):
75 """
76 It turns out we don't care much about whether creatures are demons
77 or not.
78 """
79 database['creatures'].update(
80 {'is_demon': {'$exists': True}},
81 {'$unset': {'is_demon': 1}},
82 multi=True)
83
84
85 @RegisterMigration(4, TEST_MIGRATION_REGISTRY)
86 def level_exits_dict_to_list(database):
87 """
88 For the sake of the indexes we want to write, and because we
89 intend to add more flexible fields, we want to move level exits
90 from like:
91
92 {'big_door': 'castle_level_id',
93 'trapdoor': 'dungeon_level_id'}
94
95 to like:
96
97 [{'name': 'big_door',
98 'exits_to': 'castle_level_id'},
99 {'name': 'trapdoor',
100 'exits_to': 'dungeon_level_id'}]
101 """
102 collection = database['levels']
103 target = collection.find(
104 {'exits': {'$type': 3}})
105
106 for level in target:
107 new_exits = []
108 for exit_name, exits_to in level['exits'].items():
109 new_exits.append(
110 {'name': exit_name,
111 'exits_to': exits_to})
112
113 level['exits'] = new_exits
114 collection.save(level)
115
116
117 CENTIPEDE_OBJECTID = ObjectId()
118 WOLF_OBJECTID = ObjectId()
119 WIZARDSNAKE_OBJECTID = ObjectId()
120
121 UNMIGRATED_DBDATA = {
122 'creatures': [
123 {'_id': CENTIPEDE_OBJECTID,
124 'name': 'centipede',
125 'num_legs': 100,
126 'is_demon': False},
127 {'_id': WOLF_OBJECTID,
128 'name': 'wolf',
129 'num_legs': 4,
130 'is_demon': False},
131 # don't ask me what a wizardsnake is.
132 {'_id': WIZARDSNAKE_OBJECTID,
133 'name': 'wizardsnake',
134 'num_legs': 0,
135 'is_demon': True}],
136 'levels': [
137 {'_id': 'necroplex',
138 'name': 'The Necroplex',
139 'description': 'A complex full of pure deathzone.',
140 'exits': {
141 'deathwell': 'evilstorm',
142 'portal': 'central_park'}},
143 {'_id': 'evilstorm',
144 'name': 'Evil Storm',
145 'description': 'A storm full of pure evil.',
146 'exits': {}}, # you can't escape the evilstorm
147 {'_id': 'central_park',
148 'name': 'Central Park, NY, NY',
149 'description': "New York's friendly Central Park.",
150 'exits': {
151 'portal': 'necroplex'}}]}
152
153
154 EXPECTED_POST_MIGRATION_UNMIGRATED_DBDATA = {
155 'creatures': [
156 {'_id': CENTIPEDE_OBJECTID,
157 'name': 'centipede',
158 'num_limbs': 100,
159 'magical_powers': []},
160 {'_id': WOLF_OBJECTID,
161 'name': 'wolf',
162 'num_limbs': 4,
163 # kept around namely to check that it *isn't* removed!
164 'magical_powers': []},
165 {'_id': WIZARDSNAKE_OBJECTID,
166 'name': 'wizardsnake',
167 'num_limbs': 0,
168 'magical_powers': []}],
169 'levels': [
170 {'_id': 'necroplex',
171 'name': 'The Necroplex',
172 'description': 'A complex full of pure deathzone.',
173 'exits': [
174 {'name': 'deathwell',
175 'exits_to': 'evilstorm'},
176 {'name': 'portal',
177 'exits_to': 'central_park'}]},
178 {'_id': 'evilstorm',
179 'name': 'Evil Storm',
180 'description': 'A storm full of pure evil.',
181 'exits': []}, # you can't escape the evilstorm
182 {'_id': 'central_park',
183 'name': 'Central Park, NY, NY',
184 'description': "New York's friendly Central Park.",
185 'exits': [
186 {'name': 'portal',
187 'exits_to': 'necroplex'}]}]}
188
189 # We want to make sure that if we're at migration 3, migration 3
190 # doesn't get re-run.
191
192 SEMI_MIGRATED_DBDATA = {
193 'creatures': [
194 {'_id': CENTIPEDE_OBJECTID,
195 'name': 'centipede',
196 'num_limbs': 100,
197 'magical_powers': []},
198 {'_id': WOLF_OBJECTID,
199 'name': 'wolf',
200 'num_limbs': 4,
201 # kept around namely to check that it *isn't* removed!
202 'is_demon': False,
203 'magical_powers': [
204 'ice_breath', 'death_stare']},
205 {'_id': WIZARDSNAKE_OBJECTID,
206 'name': 'wizardsnake',
207 'num_limbs': 0,
208 'magical_powers': [
209 'death_rattle', 'sneaky_stare',
210 'slithery_smoke', 'treacherous_tremors'],
211 'is_demon': True}],
212 'levels': [
213 {'_id': 'necroplex',
214 'name': 'The Necroplex',
215 'description': 'A complex full of pure deathzone.',
216 'exits': {
217 'deathwell': 'evilstorm',
218 'portal': 'central_park'}},
219 {'_id': 'evilstorm',
220 'name': 'Evil Storm',
221 'description': 'A storm full of pure evil.',
222 'exits': {}}, # you can't escape the evilstorm
223 {'_id': 'central_park',
224 'name': 'Central Park, NY, NY',
225 'description': "New York's friendly Central Park.",
226 'exits': {
227 'portal': 'necroplex'}}]}
228
229
230 EXPECTED_POST_MIGRATION_SEMI_MIGRATED_DBDATA = {
231 'creatures': [
232 {'_id': CENTIPEDE_OBJECTID,
233 'name': 'centipede',
234 'num_limbs': 100,
235 'magical_powers': []},
236 {'_id': WOLF_OBJECTID,
237 'name': 'wolf',
238 'num_limbs': 4,
239 # kept around namely to check that it *isn't* removed!
240 'is_demon': False,
241 'magical_powers': [
242 'ice_breath', 'death_stare']},
243 {'_id': WIZARDSNAKE_OBJECTID,
244 'name': 'wizardsnake',
245 'num_limbs': 0,
246 'magical_powers': [
247 'death_rattle', 'sneaky_stare',
248 'slithery_smoke', 'treacherous_tremors'],
249 'is_demon': True}],
250 'levels': [
251 {'_id': 'necroplex',
252 'name': 'The Necroplex',
253 'description': 'A complex full of pure deathzone.',
254 'exits': [
255 {'name': 'deathwell',
256 'exits_to': 'evilstorm'},
257 {'name': 'portal',
258 'exits_to': 'central_park'}]},
259 {'_id': 'evilstorm',
260 'name': 'Evil Storm',
261 'description': 'A storm full of pure evil.',
262 'exits': []}, # you can't escape the evilstorm
263 {'_id': 'central_park',
264 'name': 'Central Park, NY, NY',
265 'description': "New York's friendly Central Park.",
266 'exits': [
267 {'name': 'portal',
268 'exits_to': 'necroplex'}]}]}
269
270
271 class TestMigrations(object):
272 def setUp(self):
273 # Set up the connection, drop an existing possible database
274 self.connection = Connection()
275 self.connection.drop_database(MIGRATION_DB_NAME)
276 self.db = Connection()[MIGRATION_DB_NAME]
277 self.migration_manager = MigrationManager(
278 self.db, TEST_MIGRATION_REGISTRY)
279 self.empty_migration_manager = MigrationManager(
280 self.db, TEST_EMPTY_MIGRATION_REGISTRY)
281 self.run_migrations = []
282
283 def tearDown(self):
284 self.connection.drop_database(MIGRATION_DB_NAME)
285
286 def _record_migration(self, migration_number, migration_func):
287 self.run_migrations.append((migration_number, migration_func))
288
289 def test_migrations_registered_and_sorted(self):
290 """
291 Make sure that migrations get registered and are sorted right
292 in the migration manager
293 """
294 assert TEST_MIGRATION_REGISTRY == {
295 1: creature_add_magical_powers,
296 2: creature_rename_num_legs_to_num_limbs,
297 3: creature_remove_is_demon,
298 4: level_exits_dict_to_list}
299 assert self.migration_manager.sorted_migrations == [
300 (1, creature_add_magical_powers),
301 (2, creature_rename_num_legs_to_num_limbs),
302 (3, creature_remove_is_demon),
303 (4, level_exits_dict_to_list)]
304 assert self.empty_migration_manager.sorted_migrations == []
305
306 def test_run_full_migrations(self):
307 """
308 Make sure that running the full migration suite from 0 updates
309 everything
310 """
311 self.migration_manager.set_current_migration(0)
312 assert self.migration_manager.database_current_migration() == 0
313 install_fixtures_simple(self.db, UNMIGRATED_DBDATA)
314 self.migration_manager.migrate_new(post_callback=self._record_migration)
315
316 assert self.run_migrations == [
317 (1, creature_add_magical_powers),
318 (2, creature_rename_num_legs_to_num_limbs),
319 (3, creature_remove_is_demon),
320 (4, level_exits_dict_to_list)]
321
322 assert_db_meets_expected(
323 self.db, EXPECTED_POST_MIGRATION_UNMIGRATED_DBDATA)
324
325 # Make sure the migration is recorded correctly
326 assert self.migration_manager.database_current_migration() == 4
327
328 # run twice! It should do nothing the second time.
329 # ------------------------------------------------
330 self.run_migrations = []
331 self.migration_manager.migrate_new(post_callback=self._record_migration)
332 assert self.run_migrations == []
333 assert_db_meets_expected(
334 self.db, EXPECTED_POST_MIGRATION_UNMIGRATED_DBDATA)
335 assert self.migration_manager.database_current_migration() == 4
336
337
338 def test_run_partial_migrations(self):
339 """
340 Make sure that running full migration suite from 3 only runs
341 last migration
342 """
343 self.migration_manager.set_current_migration(3)
344 assert self.migration_manager.database_current_migration() == 3
345 install_fixtures_simple(self.db, SEMI_MIGRATED_DBDATA)
346 self.migration_manager.migrate_new(post_callback=self._record_migration)
347
348 assert self.run_migrations == [
349 (4, level_exits_dict_to_list)]
350
351 assert_db_meets_expected(
352 self.db, EXPECTED_POST_MIGRATION_SEMI_MIGRATED_DBDATA)
353
354 # Make sure the migration is recorded correctly
355 assert self.migration_manager.database_current_migration() == 4
356
357 def test_migrations_recorded_as_latest(self):
358 """
359 Make sure that if we don't have a migration_status
360 pre-recorded it's marked as the latest
361 """
362 self.migration_manager.install_migration_version_if_missing()
363 assert self.migration_manager.database_current_migration() == 4
364
365 def test_no_migrations_recorded_as_zero(self):
366 """
367 Make sure that if we don't have a migration_status
368 but there *are* no migrations that it's marked as 0
369 """
370 self.empty_migration_manager.install_migration_version_if_missing()
371 assert self.empty_migration_manager.database_current_migration() == 0
372
373 def test_migrations_to_run(self):
374 """
375 Make sure we get the right list of migrations to run
376 """
377 self.migration_manager.set_current_migration(0)
378
379 assert self.migration_manager.migrations_to_run() == [
380 (1, creature_add_magical_powers),
381 (2, creature_rename_num_legs_to_num_limbs),
382 (3, creature_remove_is_demon),
383 (4, level_exits_dict_to_list)]
384
385 self.migration_manager.set_current_migration(3)
386
387 assert self.migration_manager.migrations_to_run() == [
388 (4, level_exits_dict_to_list)]
389
390 self.migration_manager.set_current_migration(4)
391
392 assert self.migration_manager.migrations_to_run() == []
393
394
395 def test_no_migrations_raises_exception(self):
396 """
397 If we don't have the current migration set in the database,
398 this should error out.
399 """
400 assert_raises(
401 MissingCurrentMigration,
402 self.migration_manager.migrations_to_run)