22from django .core .cache import cache
33from django .urls import reverse
44
5+ from contentcuration .constants import community_library_submission as cls_constants
56from contentcuration .models import ChannelVersion
7+ from contentcuration .models import CommunityLibrarySubmission
68from contentcuration .tests .base import BaseAPITestCase
79from contentcuration .tests .testdata import generated_base64encoding
810
@@ -110,7 +112,7 @@ def test_public_channel_lookup_with_channel_version_token_uses_channel_version(
110112 "get_public_channel_lookup" ,
111113 kwargs = {"version" : "v1" , "identifier" : version_token },
112114 )
113- response = self .client .get (lookup_url )
115+ response = self .client .get (lookup_url + "?channel_versions=true" )
114116
115117 self .assertEqual (response .status_code , 200 )
116118 self .assertEqual (len (response .data ), 1 )
@@ -155,6 +157,7 @@ def test_public_channel_lookup_channel_version_and_channel_tokens_have_same_keys
155157 "get_public_channel_lookup" ,
156158 kwargs = {"version" : "v1" , "identifier" : latest_version_token },
157159 )
160+ + "?channel_versions=true"
158161 )
159162 channel_response = self .client .get (
160163 reverse (
@@ -206,7 +209,7 @@ def test_channel_version_token_returns_snapshot_info_not_current_channel_info(se
206209 "get_public_channel_lookup" ,
207210 kwargs = {"version" : "v1" , "identifier" : version_token },
208211 )
209- response = self .client .get (lookup_url )
212+ response = self .client .get (lookup_url + "?channel_versions=true" )
210213
211214 self .assertEqual (response .status_code , 200 )
212215 self .assertEqual (len (response .data ), 1 )
@@ -221,3 +224,256 @@ def test_channel_version_token_returns_snapshot_info_not_current_channel_info(se
221224 self .channel .refresh_from_db ()
222225 self .assertNotEqual (result ["name" ], self .channel .name )
223226 self .assertNotEqual (result ["description" ], self .channel .description )
227+
228+ def test_channel_version_token_lookup_requires_channel_versions_param (self ):
229+ """
230+ Without channel_versions=true, a channel-version token must return 404.
231+ With channel_versions=true it must return 200 with the correct version.
232+ """
233+ self .channel .main_tree .published = True
234+ self .channel .main_tree .save ()
235+ self .channel .version = 4
236+ self .channel .published_data = {"4" : {"version_notes" : "v4 notes" }}
237+ self .channel .save ()
238+ # Channel.on_update() auto-creates ChannelVersion(version=4) when channel.save() is called.
239+ # The get_or_create below finds that existing record; defaults are not applied.
240+ # new_token() creates the secret token if it doesn't already exist.
241+ channel_version , _created = ChannelVersion .objects .get_or_create (
242+ channel = self .channel ,
243+ version = 4 ,
244+ defaults = {
245+ "kind_count" : [],
246+ "included_languages" : [],
247+ "resource_count" : 0 ,
248+ "size" : 0 ,
249+ },
250+ )
251+ version_token = channel_version .new_token ().token
252+
253+ lookup_url = reverse (
254+ "get_public_channel_lookup" ,
255+ kwargs = {"version" : "v1" , "identifier" : version_token },
256+ )
257+
258+ # Without the param: must 404
259+ response = self .client .get (lookup_url )
260+ self .assertEqual (response .status_code , 404 )
261+
262+ # With channel_versions=true: must 200 with the correct version
263+ response = self .client .get (lookup_url + "?channel_versions=true" )
264+ self .assertEqual (response .status_code , 200 )
265+ self .assertEqual (len (response .data ), 1 )
266+ self .assertEqual (response .data [0 ]["version" ], 4 )
267+
268+ def test_channel_version_token_without_param_returns_404 (self ):
269+ """
270+ A channel-version token used without ?channel_versions=true returns 404.
271+ The gate must be active by default so older Kolibri clients never
272+ accidentally receive data they cannot parse correctly.
273+ """
274+ self .channel .main_tree .published = True
275+ self .channel .main_tree .save ()
276+ self .channel .version = 11
277+ self .channel .published_data = {"11" : {"version_notes" : "v11 notes" }}
278+ self .channel .save ()
279+
280+ channel_version , _created = ChannelVersion .objects .get_or_create (
281+ channel = self .channel ,
282+ version = 11 ,
283+ defaults = {
284+ "kind_count" : [],
285+ "included_languages" : [],
286+ "resource_count" : 0 ,
287+ "size" : 0 ,
288+ },
289+ )
290+ version_token = channel_version .new_token ().token
291+
292+ lookup_url = reverse (
293+ "get_public_channel_lookup" ,
294+ kwargs = {"version" : "v1" , "identifier" : version_token },
295+ )
296+
297+ response = self .client .get (lookup_url )
298+ self .assertEqual (response .status_code , 404 )
299+
300+ def test_channel_version_token_with_approved_submission_returns_library_community (
301+ self ,
302+ ):
303+ """
304+ A channel-version token whose ChannelVersion has a CommunityLibrarySubmission
305+ with APPROVED status returns library: "COMMUNITY".
306+ """
307+ self .channel .main_tree .published = True
308+ self .channel .main_tree .save ()
309+ self .channel .version = 5
310+ self .channel .published_data = {"5" : {"version_notes" : "v5 notes" }}
311+ self .channel .save ()
312+
313+ # CommunityLibrarySubmission.save() calls ChannelVersion.objects.get_or_create(version=5)
314+ # (finding the one already created by Channel.on_update()) and then calls new_token()
315+ # to create the secret token. self.user is already an editor of self.channel (from setUp).
316+ CommunityLibrarySubmission .objects .create (
317+ channel = self .channel ,
318+ channel_version = 5 ,
319+ author = self .user ,
320+ status = cls_constants .STATUS_APPROVED ,
321+ )
322+
323+ channel_version = ChannelVersion .objects .get (channel = self .channel , version = 5 )
324+ version_token = channel_version .secret_token .token
325+
326+ lookup_url = (
327+ reverse (
328+ "get_public_channel_lookup" ,
329+ kwargs = {"version" : "v1" , "identifier" : version_token },
330+ )
331+ + "?channel_versions=true"
332+ )
333+ response = self .client .get (lookup_url )
334+ self .assertEqual (response .status_code , 200 )
335+ self .assertEqual (response .data [0 ]["library" ], "COMMUNITY" )
336+
337+ def test_channel_version_token_with_live_submission_returns_library_community (self ):
338+ """
339+ A channel-version token whose ChannelVersion has a CommunityLibrarySubmission
340+ with LIVE status returns library: "COMMUNITY".
341+ """
342+ self .channel .main_tree .published = True
343+ self .channel .main_tree .save ()
344+ self .channel .version = 7
345+ self .channel .published_data = {"7" : {"version_notes" : "v7 notes" }}
346+ self .channel .save ()
347+
348+ # CommunityLibrarySubmission.save() validates that self.channel.public is False
349+ # (it is False by default) and that self.user is a channel editor (added in setUp).
350+ # It also calls ChannelVersion.objects.get_or_create(version=7) and new_token().
351+ CommunityLibrarySubmission .objects .create (
352+ channel = self .channel ,
353+ channel_version = 7 ,
354+ author = self .user ,
355+ status = cls_constants .STATUS_LIVE ,
356+ )
357+
358+ channel_version = ChannelVersion .objects .get (channel = self .channel , version = 7 )
359+ version_token = channel_version .secret_token .token
360+
361+ lookup_url = (
362+ reverse (
363+ "get_public_channel_lookup" ,
364+ kwargs = {"version" : "v1" , "identifier" : version_token },
365+ )
366+ + "?channel_versions=true"
367+ )
368+ response = self .client .get (lookup_url )
369+ self .assertEqual (response .status_code , 200 )
370+ self .assertEqual (response .data [0 ]["library" ], "COMMUNITY" )
371+
372+ def test_channel_version_token_with_pending_submission_returns_library_null (self ):
373+ """
374+ A channel-version token whose ChannelVersion has a CommunityLibrarySubmission
375+ with PENDING status (not approved or live) returns library: null.
376+ This validates that the status filter in _get_channel_version_library is correct.
377+ """
378+ self .channel .main_tree .published = True
379+ self .channel .main_tree .save ()
380+ self .channel .version = 8
381+ self .channel .published_data = {"8" : {"version_notes" : "v8 notes" }}
382+ self .channel .save ()
383+
384+ # CommunityLibrarySubmission with PENDING status should NOT qualify.
385+ CommunityLibrarySubmission .objects .create (
386+ channel = self .channel ,
387+ channel_version = 8 ,
388+ author = self .user ,
389+ status = cls_constants .STATUS_PENDING ,
390+ )
391+
392+ channel_version = ChannelVersion .objects .get (channel = self .channel , version = 8 )
393+ version_token = channel_version .secret_token .token
394+
395+ lookup_url = (
396+ reverse (
397+ "get_public_channel_lookup" ,
398+ kwargs = {"version" : "v1" , "identifier" : version_token },
399+ )
400+ + "?channel_versions=true"
401+ )
402+ response = self .client .get (lookup_url )
403+ self .assertEqual (response .status_code , 200 )
404+ self .assertIsNone (response .data [0 ]["library" ])
405+
406+ def test_channel_version_token_without_submission_returns_library_null (self ):
407+ """
408+ A channel-version token with no associated CommunityLibrarySubmission
409+ returns library: null.
410+ """
411+ self .channel .main_tree .published = True
412+ self .channel .main_tree .save ()
413+ self .channel .version = 6
414+ self .channel .published_data = {"6" : {"version_notes" : "v6 notes" }}
415+ self .channel .save ()
416+
417+ # Channel.on_update() creates ChannelVersion(version=6); get_or_create finds it.
418+ # No CommunityLibrarySubmission is created, so no token is auto-generated.
419+ # new_token() creates the secret token here.
420+ channel_version , _created = ChannelVersion .objects .get_or_create (
421+ channel = self .channel ,
422+ version = 6 ,
423+ defaults = {
424+ "kind_count" : [],
425+ "included_languages" : [],
426+ "resource_count" : 0 ,
427+ "size" : 0 ,
428+ },
429+ )
430+ version_token = channel_version .new_token ().token
431+
432+ lookup_url = (
433+ reverse (
434+ "get_public_channel_lookup" ,
435+ kwargs = {"version" : "v1" , "identifier" : version_token },
436+ )
437+ + "?channel_versions=true"
438+ )
439+ response = self .client .get (lookup_url )
440+ self .assertEqual (response .status_code , 200 )
441+ self .assertIsNone (response .data [0 ]["library" ])
442+
443+ def test_public_channel_token_returns_library_kolibri (self ):
444+ """
445+ A regular channel token for a public channel returns library: "KOLIBRI".
446+ """
447+ self .channel .public = True
448+ self .channel .main_tree .published = True
449+ self .channel .main_tree .save ()
450+ self .channel .save ()
451+
452+ channel_token = self .channel .make_token ().token
453+
454+ lookup_url = reverse (
455+ "get_public_channel_lookup" ,
456+ kwargs = {"version" : "v1" , "identifier" : channel_token },
457+ )
458+ response = self .client .get (lookup_url )
459+ self .assertEqual (response .status_code , 200 )
460+ self .assertEqual (response .data [0 ]["library" ], "KOLIBRI" )
461+
462+ def test_non_public_channel_token_returns_library_null (self ):
463+ """
464+ A regular channel token for a non-public channel returns library: null.
465+ """
466+ self .channel .public = False
467+ self .channel .main_tree .published = True
468+ self .channel .main_tree .save ()
469+ self .channel .save ()
470+
471+ channel_token = self .channel .make_token ().token
472+
473+ lookup_url = reverse (
474+ "get_public_channel_lookup" ,
475+ kwargs = {"version" : "v1" , "identifier" : channel_token },
476+ )
477+ response = self .client .get (lookup_url )
478+ self .assertEqual (response .status_code , 200 )
479+ self .assertIsNone (response .data [0 ]["library" ])
0 commit comments