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