From 7f52c705d7998b6707113fe6d5e637feb6fafefc Mon Sep 17 00:00:00 2001
From: Ernesto Rico Schmidt <ernesto@helsinki.at>
Date: Mon, 8 May 2023 15:58:09 -0400
Subject: [PATCH] Add tests with pytests

- Add `pytest-factoryboy` dependency
- Add pytest options
- Add factories for User, Host, Image, FundingCategory, Type, Show, RRule, Schedule and Timeslot
- Add pytest fixtures
- Add tests for Schedules, Shows, Timeslots ma Users
---
 poetry.lock                     | 312 ++++++++++++++++++++------------
 program/tests/factories.py      |  83 +++++++++
 program/tests/test_schedules.py | 220 ++++++++++++++++++++++
 program/tests/test_shows.py     |  86 +++++++++
 program/tests/test_timeslots.py |  95 ++++++++++
 program/tests/test_users.py     | 162 +++++++++++++++++
 pyproject.toml                  |  10 +
 7 files changed, 854 insertions(+), 114 deletions(-)
 create mode 100644 program/tests/factories.py
 create mode 100644 program/tests/test_schedules.py
 create mode 100644 program/tests/test_shows.py
 create mode 100644 program/tests/test_timeslots.py
 create mode 100644 program/tests/test_users.py

diff --git a/poetry.lock b/poetry.lock
index 09f26e2d..adea804e 100644
--- a/poetry.lock
+++ b/poetry.lock
@@ -1,4 +1,4 @@
-# This file is automatically @generated by Poetry 1.4.0 and should not be changed by hand.
+# This file is automatically @generated by Poetry 1.4.2 and should not be changed by hand.
 
 [[package]]
 name = "asgiref"
@@ -17,51 +17,65 @@ tests = ["mypy (>=0.800)", "pytest", "pytest-asyncio"]
 
 [[package]]
 name = "attrs"
-version = "22.2.0"
+version = "23.1.0"
 description = "Classes Without Boilerplate"
 category = "main"
 optional = false
-python-versions = ">=3.6"
+python-versions = ">=3.7"
 files = [
-    {file = "attrs-22.2.0-py3-none-any.whl", hash = "sha256:29e95c7f6778868dbd49170f98f8818f78f3dc5e0e37c0b1f474e3561b240836"},
-    {file = "attrs-22.2.0.tar.gz", hash = "sha256:c9227bfc2f01993c03f68db37d1d15c9690188323c067c641f1a35ca58185f99"},
+    {file = "attrs-23.1.0-py3-none-any.whl", hash = "sha256:1f28b4522cdc2fb4256ac1a020c78acf9cba2c6b461ccd2c126f3aa8e8335d04"},
+    {file = "attrs-23.1.0.tar.gz", hash = "sha256:6279836d581513a26f1bf235f9acd333bc9115683f14f7e8fae46c98fc50e015"},
 ]
 
 [package.extras]
-cov = ["attrs[tests]", "coverage-enable-subprocess", "coverage[toml] (>=5.3)"]
-dev = ["attrs[docs,tests]"]
-docs = ["furo", "myst-parser", "sphinx", "sphinx-notfound-page", "sphinxcontrib-towncrier", "towncrier", "zope.interface"]
-tests = ["attrs[tests-no-zope]", "zope.interface"]
-tests-no-zope = ["cloudpickle", "cloudpickle", "hypothesis", "hypothesis", "mypy (>=0.971,<0.990)", "mypy (>=0.971,<0.990)", "pympler", "pympler", "pytest (>=4.3.0)", "pytest (>=4.3.0)", "pytest-mypy-plugins", "pytest-mypy-plugins", "pytest-xdist[psutil]", "pytest-xdist[psutil]"]
+cov = ["attrs[tests]", "coverage[toml] (>=5.3)"]
+dev = ["attrs[docs,tests]", "pre-commit"]
+docs = ["furo", "myst-parser", "sphinx", "sphinx-notfound-page", "sphinxcontrib-towncrier", "towncrier", "zope-interface"]
+tests = ["attrs[tests-no-zope]", "zope-interface"]
+tests-no-zope = ["cloudpickle", "hypothesis", "mypy (>=1.1.1)", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins", "pytest-xdist[psutil]"]
 
 [[package]]
 name = "black"
-version = "22.12.0"
+version = "23.3.0"
 description = "The uncompromising code formatter."
 category = "dev"
 optional = false
 python-versions = ">=3.7"
 files = [
-    {file = "black-22.12.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9eedd20838bd5d75b80c9f5487dbcb06836a43833a37846cf1d8c1cc01cef59d"},
-    {file = "black-22.12.0-cp310-cp310-win_amd64.whl", hash = "sha256:159a46a4947f73387b4d83e87ea006dbb2337eab6c879620a3ba52699b1f4351"},
-    {file = "black-22.12.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d30b212bffeb1e252b31dd269dfae69dd17e06d92b87ad26e23890f3efea366f"},
-    {file = "black-22.12.0-cp311-cp311-win_amd64.whl", hash = "sha256:7412e75863aa5c5411886804678b7d083c7c28421210180d67dfd8cf1221e1f4"},
-    {file = "black-22.12.0-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c116eed0efb9ff870ded8b62fe9f28dd61ef6e9ddd28d83d7d264a38417dcee2"},
-    {file = "black-22.12.0-cp37-cp37m-win_amd64.whl", hash = "sha256:1f58cbe16dfe8c12b7434e50ff889fa479072096d79f0a7f25e4ab8e94cd8350"},
-    {file = "black-22.12.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:77d86c9f3db9b1bf6761244bc0b3572a546f5fe37917a044e02f3166d5aafa7d"},
-    {file = "black-22.12.0-cp38-cp38-win_amd64.whl", hash = "sha256:82d9fe8fee3401e02e79767016b4907820a7dc28d70d137eb397b92ef3cc5bfc"},
-    {file = "black-22.12.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:101c69b23df9b44247bd88e1d7e90154336ac4992502d4197bdac35dd7ee3320"},
-    {file = "black-22.12.0-cp39-cp39-win_amd64.whl", hash = "sha256:559c7a1ba9a006226f09e4916060982fd27334ae1998e7a38b3f33a37f7a2148"},
-    {file = "black-22.12.0-py3-none-any.whl", hash = "sha256:436cc9167dd28040ad90d3b404aec22cedf24a6e4d7de221bec2730ec0c97bcf"},
-    {file = "black-22.12.0.tar.gz", hash = "sha256:229351e5a18ca30f447bf724d007f890f97e13af070bb6ad4c0a441cd7596a2f"},
+    {file = "black-23.3.0-cp310-cp310-macosx_10_16_arm64.whl", hash = "sha256:0945e13506be58bf7db93ee5853243eb368ace1c08a24c65ce108986eac65915"},
+    {file = "black-23.3.0-cp310-cp310-macosx_10_16_universal2.whl", hash = "sha256:67de8d0c209eb5b330cce2469503de11bca4085880d62f1628bd9972cc3366b9"},
+    {file = "black-23.3.0-cp310-cp310-macosx_10_16_x86_64.whl", hash = "sha256:7c3eb7cea23904399866c55826b31c1f55bbcd3890ce22ff70466b907b6775c2"},
+    {file = "black-23.3.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:32daa9783106c28815d05b724238e30718f34155653d4d6e125dc7daec8e260c"},
+    {file = "black-23.3.0-cp310-cp310-win_amd64.whl", hash = "sha256:35d1381d7a22cc5b2be2f72c7dfdae4072a3336060635718cc7e1ede24221d6c"},
+    {file = "black-23.3.0-cp311-cp311-macosx_10_16_arm64.whl", hash = "sha256:a8a968125d0a6a404842fa1bf0b349a568634f856aa08ffaff40ae0dfa52e7c6"},
+    {file = "black-23.3.0-cp311-cp311-macosx_10_16_universal2.whl", hash = "sha256:c7ab5790333c448903c4b721b59c0d80b11fe5e9803d8703e84dcb8da56fec1b"},
+    {file = "black-23.3.0-cp311-cp311-macosx_10_16_x86_64.whl", hash = "sha256:a6f6886c9869d4daae2d1715ce34a19bbc4b95006d20ed785ca00fa03cba312d"},
+    {file = "black-23.3.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6f3c333ea1dd6771b2d3777482429864f8e258899f6ff05826c3a4fcc5ce3f70"},
+    {file = "black-23.3.0-cp311-cp311-win_amd64.whl", hash = "sha256:11c410f71b876f961d1de77b9699ad19f939094c3a677323f43d7a29855fe326"},
+    {file = "black-23.3.0-cp37-cp37m-macosx_10_16_x86_64.whl", hash = "sha256:1d06691f1eb8de91cd1b322f21e3bfc9efe0c7ca1f0e1eb1db44ea367dff656b"},
+    {file = "black-23.3.0-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:50cb33cac881766a5cd9913e10ff75b1e8eb71babf4c7104f2e9c52da1fb7de2"},
+    {file = "black-23.3.0-cp37-cp37m-win_amd64.whl", hash = "sha256:e114420bf26b90d4b9daa597351337762b63039752bdf72bf361364c1aa05925"},
+    {file = "black-23.3.0-cp38-cp38-macosx_10_16_arm64.whl", hash = "sha256:48f9d345675bb7fbc3dd85821b12487e1b9a75242028adad0333ce36ed2a6d27"},
+    {file = "black-23.3.0-cp38-cp38-macosx_10_16_universal2.whl", hash = "sha256:714290490c18fb0126baa0fca0a54ee795f7502b44177e1ce7624ba1c00f2331"},
+    {file = "black-23.3.0-cp38-cp38-macosx_10_16_x86_64.whl", hash = "sha256:064101748afa12ad2291c2b91c960be28b817c0c7eaa35bec09cc63aa56493c5"},
+    {file = "black-23.3.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:562bd3a70495facf56814293149e51aa1be9931567474993c7942ff7d3533961"},
+    {file = "black-23.3.0-cp38-cp38-win_amd64.whl", hash = "sha256:e198cf27888ad6f4ff331ca1c48ffc038848ea9f031a3b40ba36aced7e22f2c8"},
+    {file = "black-23.3.0-cp39-cp39-macosx_10_16_arm64.whl", hash = "sha256:3238f2aacf827d18d26db07524e44741233ae09a584273aa059066d644ca7b30"},
+    {file = "black-23.3.0-cp39-cp39-macosx_10_16_universal2.whl", hash = "sha256:f0bd2f4a58d6666500542b26354978218a9babcdc972722f4bf90779524515f3"},
+    {file = "black-23.3.0-cp39-cp39-macosx_10_16_x86_64.whl", hash = "sha256:92c543f6854c28a3c7f39f4d9b7694f9a6eb9d3c5e2ece488c327b6e7ea9b266"},
+    {file = "black-23.3.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3a150542a204124ed00683f0db1f5cf1c2aaaa9cc3495b7a3b5976fb136090ab"},
+    {file = "black-23.3.0-cp39-cp39-win_amd64.whl", hash = "sha256:6b39abdfb402002b8a7d030ccc85cf5afff64ee90fa4c5aebc531e3ad0175ddb"},
+    {file = "black-23.3.0-py3-none-any.whl", hash = "sha256:ec751418022185b0c1bb7d7736e6933d40bbb14c14a0abcf9123d1b159f98dd4"},
+    {file = "black-23.3.0.tar.gz", hash = "sha256:1c7b8d606e728a41ea1ccbd7264677e494e87cf630e399262ced92d4a8dac940"},
 ]
 
 [package.dependencies]
 click = ">=8.0.0"
 mypy-extensions = ">=0.4.3"
+packaging = ">=22.0"
 pathspec = ">=0.9.0"
 platformdirs = ">=2"
-tomli = {version = ">=1.1.0", markers = "python_full_version < \"3.11.0a7\""}
+tomli = {version = ">=1.1.0", markers = "python_version < \"3.11\""}
 
 [package.extras]
 colorama = ["colorama (>=0.4.3)"]
@@ -71,14 +85,14 @@ uvloop = ["uvloop (>=0.15.2)"]
 
 [[package]]
 name = "certifi"
-version = "2022.12.7"
+version = "2023.5.7"
 description = "Python package for providing Mozilla's CA Bundle."
 category = "main"
 optional = false
 python-versions = ">=3.6"
 files = [
-    {file = "certifi-2022.12.7-py3-none-any.whl", hash = "sha256:4ad3232f5e926d6718ec31cfc1fcadfde020920e278684144551c91769c7bc18"},
-    {file = "certifi-2022.12.7.tar.gz", hash = "sha256:35824b4c3a97115964b408844d64aa14db1cc518f6562e8d7261699d1350a9e3"},
+    {file = "certifi-2023.5.7-py3-none-any.whl", hash = "sha256:c6c2e98f5c7869efca1f8916fed228dd91539f9f1b444c314c06eef02980c716"},
+    {file = "certifi-2023.5.7.tar.gz", hash = "sha256:0f0d56dc5a6ad56fd4ba36484d6cc34451e1c6548c61daad8c320169f91eddc7"},
 ]
 
 [[package]]
@@ -219,14 +233,14 @@ files = [
 
 [[package]]
 name = "django"
-version = "3.2.18"
+version = "3.2.19"
 description = "A high-level Python Web framework that encourages rapid development and clean, pragmatic design."
 category = "main"
 optional = false
 python-versions = ">=3.6"
 files = [
-    {file = "Django-3.2.18-py3-none-any.whl", hash = "sha256:4d492d9024c7b3dfababf49f94511ab6a58e2c9c3c7207786f1ba4eb77750706"},
-    {file = "Django-3.2.18.tar.gz", hash = "sha256:08208dfe892eb64fff073ca743b3b952311104f939e7f6dae954fe72dcc533ba"},
+    {file = "Django-3.2.19-py3-none-any.whl", hash = "sha256:21cc991466245d659ab79cb01204f9515690f8dae00e5eabde307f14d24d4d7d"},
+    {file = "Django-3.2.19.tar.gz", hash = "sha256:031365bae96814da19c10706218c44dff3b654cc4de20a98bd2d29b9bde469f0"},
 ]
 
 [package.dependencies]
@@ -240,14 +254,14 @@ bcrypt = ["bcrypt"]
 
 [[package]]
 name = "django-auth-ldap"
-version = "4.2.0"
+version = "4.3.0"
 description = "Django LDAP authentication backend."
 category = "main"
 optional = false
 python-versions = ">=3.7"
 files = [
-    {file = "django-auth-ldap-4.2.0.tar.gz", hash = "sha256:aac71e65b0a8bdcfc5cd08b70997ee3cdc37786ffd5d975b7e2cfa47595d427f"},
-    {file = "django_auth_ldap-4.2.0-py3-none-any.whl", hash = "sha256:3eb0d963cd6e8225d0a588a828ce35a5c5c3309f7ad56dc5d68f8c807ddeaeff"},
+    {file = "django-auth-ldap-4.3.0.tar.gz", hash = "sha256:788b5b1ee70054681d7fae7d085deaa76f2fa6f64cc9fe3dd79daef62c2f6121"},
+    {file = "django_auth_ldap-4.3.0-py3-none-any.whl", hash = "sha256:6d18e747e1d9680360357945b03e0d16a3f50feea94176e2552f29ccf8c2973c"},
 ]
 
 [package.dependencies]
@@ -286,14 +300,14 @@ Django = ">=3.2"
 
 [[package]]
 name = "django-filter"
-version = "22.1"
+version = "23.2"
 description = "Django-filter is a reusable Django application for allowing users to filter querysets dynamically."
 category = "main"
 optional = false
 python-versions = ">=3.7"
 files = [
-    {file = "django-filter-22.1.tar.gz", hash = "sha256:ed473b76e84f7e83b2511bb2050c3efb36d135207d0128dfe3ae4b36e3594ba5"},
-    {file = "django_filter-22.1-py3-none-any.whl", hash = "sha256:ed429e34760127e3520a67f415bec4c905d4649fbe45d0d6da37e6ff5e0287eb"},
+    {file = "django-filter-23.2.tar.gz", hash = "sha256:2fe15f78108475eda525692813205fa6f9e8c1caf1ae65daa5862d403c6dbf00"},
+    {file = "django_filter-23.2-py3-none-any.whl", hash = "sha256:d12d8e0fc6d3eb26641e553e5d53b191eb8cec611427d4bdce0becb1f7c172b5"},
 ]
 
 [package.dependencies]
@@ -315,19 +329,20 @@ pyjwkest = ">=1.3.0"
 
 [[package]]
 name = "django-versatileimagefield"
-version = "2.2"
+version = "3.0"
 description = "A drop-in replacement for django's ImageField that provides a flexible, intuitive and easily-extensible interface for creating new images from the one assigned to the field."
 category = "main"
 optional = false
 python-versions = "*"
 files = [
-    {file = "django-versatileimagefield-2.2.tar.gz", hash = "sha256:6569d5c3e13c69ab8912ba5100084aa5abcdcffb8d1f5abc085b226e7bbd65b3"},
-    {file = "django_versatileimagefield-2.2-py2.py3-none-any.whl", hash = "sha256:03766f4d2332f192978879fdb196e18c53e6ccef6c55c5c26b59747b76a97288"},
+    {file = "django-versatileimagefield-3.0.tar.gz", hash = "sha256:1651db2ed36d843cfb1788f2601472a293d9ba86729369b6f6e5591112357ac7"},
+    {file = "django_versatileimagefield-3.0-py2.py3-none-any.whl", hash = "sha256:19c09c2a887a1fe14eb8c72397c52aa348b315d5fdcc43bf1a1e683283750fd7"},
 ]
 
 [package.dependencies]
+Django = ">=3.0"
 Pillow = ">=2.4.0"
-python-magic = ">=0.4.15,<1.0.0"
+python-magic = ">=0.4.22,<1.0.0"
 
 [[package]]
 name = "djangorestframework"
@@ -363,19 +378,19 @@ djangorestframework = ">=3.6.0"
 
 [[package]]
 name = "drf-spectacular"
-version = "0.24.2"
+version = "0.26.2"
 description = "Sane and flexible OpenAPI 3 schema generation for Django REST framework"
 category = "main"
 optional = false
 python-versions = ">=3.6"
 files = [
-    {file = "drf-spectacular-0.24.2.tar.gz", hash = "sha256:be32417594080a52f996afd83fd47ea9c2b83cbf13f6d3fbf3de809a0dfa7ead"},
-    {file = "drf_spectacular-0.24.2-py3-none-any.whl", hash = "sha256:b276e6f7bda6dfb911e742dab87c6e97bc67da2dafe82d6fd8df7cec6c8b03ec"},
+    {file = "drf-spectacular-0.26.2.tar.gz", hash = "sha256:005623d6bb9de37d2d0ec24ccd59c636e4a42f9af252f1470129ac32ccab38cb"},
+    {file = "drf_spectacular-0.26.2-py3-none-any.whl", hash = "sha256:e80eba58d9579bf6c3380ffd6d6a9b466c4bc35b23da0ba76dfcc96de1e907d7"},
 ]
 
 [package.dependencies]
 Django = ">=2.2"
-djangorestframework = ">=3.10"
+djangorestframework = ">=3.10.3"
 inflection = ">=0.3.1"
 jsonschema = ">=2.6.0"
 PyYAML = ">=5.1"
@@ -400,21 +415,55 @@ files = [
 [package.extras]
 test = ["pytest (>=6)"]
 
+[[package]]
+name = "factory-boy"
+version = "3.2.1"
+description = "A versatile test fixtures replacement based on thoughtbot's factory_bot for Ruby."
+category = "dev"
+optional = false
+python-versions = ">=3.6"
+files = [
+    {file = "factory_boy-3.2.1-py2.py3-none-any.whl", hash = "sha256:eb02a7dd1b577ef606b75a253b9818e6f9eaf996d94449c9d5ebb124f90dc795"},
+    {file = "factory_boy-3.2.1.tar.gz", hash = "sha256:a98d277b0c047c75eb6e4ab8508a7f81fb03d2cb21986f627913546ef7a2a55e"},
+]
+
+[package.dependencies]
+Faker = ">=0.7.0"
+
+[package.extras]
+dev = ["Django", "Pillow", "SQLAlchemy", "coverage", "flake8", "isort", "mongoengine", "tox", "wheel (>=0.32.0)", "zest.releaser[recommended]"]
+doc = ["Sphinx", "sphinx-rtd-theme", "sphinxcontrib-spelling"]
+
+[[package]]
+name = "faker"
+version = "18.7.0"
+description = "Faker is a Python package that generates fake data for you."
+category = "dev"
+optional = false
+python-versions = ">=3.7"
+files = [
+    {file = "Faker-18.7.0-py3-none-any.whl", hash = "sha256:38dbc3b80e655d7301e190426ab30f04b6b7f6ca4764c5dd02772ffde0fa6dcd"},
+    {file = "Faker-18.7.0.tar.gz", hash = "sha256:f02c6d3fdb5bc781f80b440cf2bdec336ed47ecfb8d620b20c3d4188ed051831"},
+]
+
+[package.dependencies]
+python-dateutil = ">=2.4"
+
 [[package]]
 name = "filelock"
-version = "3.11.0"
+version = "3.12.0"
 description = "A platform independent file lock."
 category = "dev"
 optional = false
 python-versions = ">=3.7"
 files = [
-    {file = "filelock-3.11.0-py3-none-any.whl", hash = "sha256:f08a52314748335c6460fc8fe40cd5638b85001225db78c2aa01c8c0db83b318"},
-    {file = "filelock-3.11.0.tar.gz", hash = "sha256:3618c0da67adcc0506b015fd11ef7faf1b493f0b40d87728e19986b536890c37"},
+    {file = "filelock-3.12.0-py3-none-any.whl", hash = "sha256:ad98852315c2ab702aeb628412cbf7e95b7ce8c3bf9565670b4eaecf1db370a9"},
+    {file = "filelock-3.12.0.tar.gz", hash = "sha256:fc03ae43288c013d2ea83c8597001b1129db351aad9c57fe2409327916b8e718"},
 ]
 
 [package.extras]
-docs = ["furo (>=2023.3.27)", "sphinx (>=6.1.3)", "sphinx-autodoc-typehints (>=1.22,!=1.23.4)"]
-testing = ["covdefaults (>=2.3)", "coverage (>=7.2.2)", "diff-cover (>=7.5)", "pytest (>=7.2.2)", "pytest-cov (>=4)", "pytest-mock (>=3.10)", "pytest-timeout (>=2.1)"]
+docs = ["furo (>=2023.3.27)", "sphinx (>=6.1.3)", "sphinx-autodoc-typehints (>=1.23,!=1.23.4)"]
+testing = ["covdefaults (>=2.3)", "coverage (>=7.2.3)", "diff-cover (>=7.5)", "pytest (>=7.3.1)", "pytest-cov (>=4)", "pytest-mock (>=3.10)", "pytest-timeout (>=2.1)"]
 
 [[package]]
 name = "flake8"
@@ -467,14 +516,14 @@ tornado = ["tornado (>=0.2)"]
 
 [[package]]
 name = "identify"
-version = "2.5.22"
+version = "2.5.24"
 description = "File identification library for Python"
 category = "dev"
 optional = false
 python-versions = ">=3.7"
 files = [
-    {file = "identify-2.5.22-py2.py3-none-any.whl", hash = "sha256:f0faad595a4687053669c112004178149f6c326db71ee999ae4636685753ad2f"},
-    {file = "identify-2.5.22.tar.gz", hash = "sha256:f7a93d6cf98e29bd07663c60728e7a4057615068d7a639d132dc883b2d54d31e"},
+    {file = "identify-2.5.24-py2.py3-none-any.whl", hash = "sha256:986dbfb38b1140e763e413e6feb44cd731faf72d1909543178aa79b0e258265d"},
+    {file = "identify-2.5.24.tar.gz", hash = "sha256:0aac67d5b4812498056d28a9a512a483f5085cc28640b02b258a59dac34301d4"},
 ]
 
 [package.extras]
@@ -655,14 +704,14 @@ setuptools = "*"
 
 [[package]]
 name = "packaging"
-version = "23.0"
+version = "23.1"
 description = "Core utilities for Python packages"
 category = "dev"
 optional = false
 python-versions = ">=3.7"
 files = [
-    {file = "packaging-23.0-py3-none-any.whl", hash = "sha256:714ac14496c3e68c99c29b00845f7a2b85f3bb6f1078fd9f72fd20f0570002b2"},
-    {file = "packaging-23.0.tar.gz", hash = "sha256:b6ad297f8907de0fa2fe1ccbd26fdaf387f5f47c7275fedf8cce89f99446cf97"},
+    {file = "packaging-23.1-py3-none-any.whl", hash = "sha256:994793af429502c4ea2ebf6bf664629d07c1a9fe974af92966e4b8d2df7edc61"},
+    {file = "packaging-23.1.tar.gz", hash = "sha256:a392980d2b6cffa644431898be54b0045151319d1e7ec34f0cfed48767dd334f"},
 ]
 
 [[package]]
@@ -759,19 +808,19 @@ tests = ["check-manifest", "coverage", "defusedxml", "markdown2", "olefile", "pa
 
 [[package]]
 name = "platformdirs"
-version = "3.2.0"
+version = "3.5.0"
 description = "A small Python package for determining appropriate platform-specific dirs, e.g. a \"user data dir\"."
 category = "dev"
 optional = false
 python-versions = ">=3.7"
 files = [
-    {file = "platformdirs-3.2.0-py3-none-any.whl", hash = "sha256:ebe11c0d7a805086e99506aa331612429a72ca7cd52a1f0d277dc4adc20cb10e"},
-    {file = "platformdirs-3.2.0.tar.gz", hash = "sha256:d5b638ca397f25f979350ff789db335903d7ea010ab28903f57b27e1b16c2b08"},
+    {file = "platformdirs-3.5.0-py3-none-any.whl", hash = "sha256:47692bc24c1958e8b0f13dd727307cff1db103fca36399f457da8e05f222fdc4"},
+    {file = "platformdirs-3.5.0.tar.gz", hash = "sha256:7954a68d0ba23558d753f73437c55f89027cf8f5108c19844d4b82e5af396335"},
 ]
 
 [package.extras]
-docs = ["furo (>=2022.12.7)", "proselint (>=0.13)", "sphinx (>=6.1.3)", "sphinx-autodoc-typehints (>=1.22,!=1.23.4)"]
-test = ["appdirs (==1.4.4)", "covdefaults (>=2.3)", "pytest (>=7.2.2)", "pytest-cov (>=4)", "pytest-mock (>=3.10)"]
+docs = ["furo (>=2023.3.27)", "proselint (>=0.13)", "sphinx (>=6.1.3)", "sphinx-autodoc-typehints (>=1.23,!=1.23.4)"]
+test = ["appdirs (==1.4.4)", "covdefaults (>=2.3)", "pytest (>=7.3.1)", "pytest-cov (>=4)", "pytest-mock (>=3.10)"]
 
 [[package]]
 name = "pluggy"
@@ -791,14 +840,14 @@ testing = ["pytest", "pytest-benchmark"]
 
 [[package]]
 name = "pre-commit"
-version = "2.21.0"
+version = "3.3.1"
 description = "A framework for managing and maintaining multi-language pre-commit hooks."
 category = "dev"
 optional = false
-python-versions = ">=3.7"
+python-versions = ">=3.8"
 files = [
-    {file = "pre_commit-2.21.0-py2.py3-none-any.whl", hash = "sha256:e2f91727039fc39a92f58a588a25b87f936de6567eed4f0e673e0507edc75bad"},
-    {file = "pre_commit-2.21.0.tar.gz", hash = "sha256:31ef31af7e474a8d8995027fefdfcf509b5c913ff31f2015b4ec4beb26a6f658"},
+    {file = "pre_commit-3.3.1-py2.py3-none-any.whl", hash = "sha256:218e9e3f7f7f3271ebc355a15598a4d3893ad9fc7b57fe446db75644543323b9"},
+    {file = "pre_commit-3.3.1.tar.gz", hash = "sha256:733f78c9a056cdd169baa6cd4272d51ecfda95346ef8a89bf93712706021b907"},
 ]
 
 [package.dependencies]
@@ -882,30 +931,30 @@ files = [
 
 [[package]]
 name = "pyasn1"
-version = "0.4.8"
-description = "ASN.1 types and codecs"
+version = "0.5.0"
+description = "Pure-Python implementation of ASN.1 types and DER/BER/CER codecs (X.208)"
 category = "main"
 optional = false
-python-versions = "*"
+python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,>=2.7"
 files = [
-    {file = "pyasn1-0.4.8-py2.py3-none-any.whl", hash = "sha256:39c7e2ec30515947ff4e87fb6f456dfc6e84857d34be479c9d4a4ba4bf46aa5d"},
-    {file = "pyasn1-0.4.8.tar.gz", hash = "sha256:aef77c9fb94a3ac588e87841208bdec464471d9871bd5050a287cc9a475cd0ba"},
+    {file = "pyasn1-0.5.0-py2.py3-none-any.whl", hash = "sha256:87a2121042a1ac9358cabcaf1d07680ff97ee6404333bacca15f76aa8ad01a57"},
+    {file = "pyasn1-0.5.0.tar.gz", hash = "sha256:97b7290ca68e62a832558ec3976f15cbf911bf5d7c7039d8b861c2a0ece69fde"},
 ]
 
 [[package]]
 name = "pyasn1-modules"
-version = "0.2.8"
-description = "A collection of ASN.1-based protocols modules."
+version = "0.3.0"
+description = "A collection of ASN.1-based protocols modules"
 category = "main"
 optional = false
-python-versions = "*"
+python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,>=2.7"
 files = [
-    {file = "pyasn1-modules-0.2.8.tar.gz", hash = "sha256:905f84c712230b2c592c19470d3ca8d552de726050d1d1716282a1f6146be65e"},
-    {file = "pyasn1_modules-0.2.8-py2.py3-none-any.whl", hash = "sha256:a50b808ffeb97cb3601dd25981f6b016cbb3d31fbf57a8b8a87428e6158d0c74"},
+    {file = "pyasn1_modules-0.3.0-py2.py3-none-any.whl", hash = "sha256:d3ccd6ed470d9ffbc716be08bd90efbd44d0734bc9303818f7336070984a162d"},
+    {file = "pyasn1_modules-0.3.0.tar.gz", hash = "sha256:5bd01446b736eb9d31512a30d46c1ac3395d676c6f3cafa4c03eb54b9925631c"},
 ]
 
 [package.dependencies]
-pyasn1 = ">=0.4.6,<0.5.0"
+pyasn1 = ">=0.4.6,<0.6.0"
 
 [[package]]
 name = "pycodestyle"
@@ -1060,18 +1109,17 @@ files = [
 
 [[package]]
 name = "pytest"
-version = "7.2.2"
+version = "7.3.1"
 description = "pytest: simple powerful testing with Python"
 category = "dev"
 optional = false
 python-versions = ">=3.7"
 files = [
-    {file = "pytest-7.2.2-py3-none-any.whl", hash = "sha256:130328f552dcfac0b1cec75c12e3f005619dc5f874f0a06e8ff7263f0ee6225e"},
-    {file = "pytest-7.2.2.tar.gz", hash = "sha256:c99ab0c73aceb050f68929bc93af19ab6db0558791c6a0715723abe9d0ade9d4"},
+    {file = "pytest-7.3.1-py3-none-any.whl", hash = "sha256:3799fa815351fea3a5e96ac7e503a96fa51cc9942c3753cda7651b93c1cfa362"},
+    {file = "pytest-7.3.1.tar.gz", hash = "sha256:434afafd78b1d78ed0addf160ad2b77a30d35d4bdf8af234fe621919d9ed15e3"},
 ]
 
 [package.dependencies]
-attrs = ">=19.2.0"
 colorama = {version = "*", markers = "sys_platform == \"win32\""}
 exceptiongroup = {version = ">=1.0.0rc8", markers = "python_version < \"3.11\""}
 iniconfig = "*"
@@ -1080,7 +1128,7 @@ pluggy = ">=0.12,<2.0"
 tomli = {version = ">=1.0.0", markers = "python_version < \"3.11\""}
 
 [package.extras]
-testing = ["argcomplete", "hypothesis (>=3.56)", "mock", "nose", "pygments (>=2.7.2)", "requests", "xmlschema"]
+testing = ["argcomplete", "attrs (>=19.2.0)", "hypothesis (>=3.56)", "mock", "nose", "pygments (>=2.7.2)", "requests", "xmlschema"]
 
 [[package]]
 name = "pytest-django"
@@ -1101,6 +1149,24 @@ pytest = ">=5.4.0"
 docs = ["sphinx", "sphinx-rtd-theme"]
 testing = ["Django", "django-configurations (>=2.0)"]
 
+[[package]]
+name = "pytest-factoryboy"
+version = "2.5.1"
+description = "Factory Boy support for pytest."
+category = "dev"
+optional = false
+python-versions = ">=3.7"
+files = [
+    {file = "pytest_factoryboy-2.5.1-py3-none-any.whl", hash = "sha256:41e3465935322188daefc8720f83cebb16bf3d3a430356dc91151c55f31d99c7"},
+    {file = "pytest_factoryboy-2.5.1.tar.gz", hash = "sha256:7275a52299b20c0f58b63fdf7326b3fd2b7cbefbdaa90fdcfc776bbe92197484"},
+]
+
+[package.dependencies]
+factory_boy = ">=2.10.0"
+inflection = "*"
+pytest = ">=5.0.0"
+typing_extensions = "*"
+
 [[package]]
 name = "python-dateutil"
 version = "2.8.2"
@@ -1145,14 +1211,14 @@ files = [
 
 [[package]]
 name = "pytz"
-version = "2022.7.1"
+version = "2023.3"
 description = "World timezone definitions, modern and historical"
 category = "main"
 optional = false
 python-versions = "*"
 files = [
-    {file = "pytz-2022.7.1-py2.py3-none-any.whl", hash = "sha256:78f4f37d8198e0627c5f1143240bb0206b8691d8d7ac6d78fee88b78733f8c4a"},
-    {file = "pytz-2022.7.1.tar.gz", hash = "sha256:01a0681c4b9684a28304615eba55d1ab31ae00bf68ec157ec3708a8182dbbcd0"},
+    {file = "pytz-2023.3-py2.py3-none-any.whl", hash = "sha256:a151b3abb88eda1d4e34a9814df37de2a80e301e68ba0fd856fb9b46bfbbbffb"},
+    {file = "pytz-2023.3.tar.gz", hash = "sha256:1d8ce29db189191fb55338ee6d0387d82ab59f3d00eac103412d64e0ebd0c588"},
 ]
 
 [[package]]
@@ -1207,21 +1273,21 @@ files = [
 
 [[package]]
 name = "requests"
-version = "2.28.2"
+version = "2.30.0"
 description = "Python HTTP for Humans."
 category = "main"
 optional = false
-python-versions = ">=3.7, <4"
+python-versions = ">=3.7"
 files = [
-    {file = "requests-2.28.2-py3-none-any.whl", hash = "sha256:64299f4909223da747622c030b781c0d7811e359c37124b4bd368fb8c6518baa"},
-    {file = "requests-2.28.2.tar.gz", hash = "sha256:98b1b2782e3c6c4904938b84c0eb932721069dfdb9134313beff7c83c2df24bf"},
+    {file = "requests-2.30.0-py3-none-any.whl", hash = "sha256:10e94cc4f3121ee6da529d358cdaeaff2f1c409cd377dbc72b825852f2f7e294"},
+    {file = "requests-2.30.0.tar.gz", hash = "sha256:239d7d4458afcb28a692cdd298d87542235f4ca8d36d03a15bfc128a6559a2f4"},
 ]
 
 [package.dependencies]
 certifi = ">=2017.4.17"
 charset-normalizer = ">=2,<4"
 idna = ">=2.5,<4"
-urllib3 = ">=1.21.1,<1.27"
+urllib3 = ">=1.21.1,<3"
 
 [package.extras]
 socks = ["PySocks (>=1.5.6,!=1.5.7)"]
@@ -1229,14 +1295,14 @@ use-chardet-on-py3 = ["chardet (>=3.0.2,<6)"]
 
 [[package]]
 name = "setuptools"
-version = "67.6.1"
+version = "67.7.2"
 description = "Easily download, build, install, upgrade, and uninstall Python packages"
 category = "main"
 optional = false
 python-versions = ">=3.7"
 files = [
-    {file = "setuptools-67.6.1-py3-none-any.whl", hash = "sha256:e728ca814a823bf7bf60162daf9db95b93d532948c4c0bea762ce62f60189078"},
-    {file = "setuptools-67.6.1.tar.gz", hash = "sha256:257de92a9d50a60b8e22abfcbb771571fde0dbf3ec234463212027a4eeecbe9a"},
+    {file = "setuptools-67.7.2-py3-none-any.whl", hash = "sha256:23aaf86b85ca52ceb801d32703f12d77517b2556af839621c641fca11287952b"},
+    {file = "setuptools-67.7.2.tar.gz", hash = "sha256:f104fa03692a2602fa0fec6c6a9e63b6c8a968de13e17c026957dd1f53d80990"},
 ]
 
 [package.extras]
@@ -1258,16 +1324,21 @@ files = [
 
 [[package]]
 name = "sqlparse"
-version = "0.4.3"
+version = "0.4.4"
 description = "A non-validating SQL parser."
 category = "main"
 optional = false
 python-versions = ">=3.5"
 files = [
-    {file = "sqlparse-0.4.3-py3-none-any.whl", hash = "sha256:0323c0ec29cd52bceabc1b4d9d579e311f3e4961b98d174201d5622a23b85e34"},
-    {file = "sqlparse-0.4.3.tar.gz", hash = "sha256:69ca804846bb114d2ec380e4360a8a340db83f0ccf3afceeb1404df028f57268"},
+    {file = "sqlparse-0.4.4-py3-none-any.whl", hash = "sha256:5430a4fe2ac7d0f93e66f1efc6e1338a41884b7ddf2a350cedd20ccc4d9d28f3"},
+    {file = "sqlparse-0.4.4.tar.gz", hash = "sha256:d446183e84b8349fa3061f0fe7f06ca94ba65b426946ffebe6e3e8295332420c"},
 ]
 
+[package.extras]
+dev = ["build", "flake8"]
+doc = ["sphinx"]
+test = ["pytest", "pytest-cov"]
+
 [[package]]
 name = "tomli"
 version = "2.0.1"
@@ -1280,6 +1351,18 @@ files = [
     {file = "tomli-2.0.1.tar.gz", hash = "sha256:de526c12914f0c550d15924c62d72abc48d6fe7364aa87328337a31007fe8a4f"},
 ]
 
+[[package]]
+name = "typing-extensions"
+version = "4.5.0"
+description = "Backported and Experimental Type Hints for Python 3.7+"
+category = "dev"
+optional = false
+python-versions = ">=3.7"
+files = [
+    {file = "typing_extensions-4.5.0-py3-none-any.whl", hash = "sha256:fb33085c39dd998ac16d1431ebc293a8b3eedd00fd4a32de0ff79002c19511b4"},
+    {file = "typing_extensions-4.5.0.tar.gz", hash = "sha256:5cb5f4a79139d699607b3ef622a1dedafa84e115ab0024e0d9c044a9479ca7cb"},
+]
+
 [[package]]
 name = "uritemplate"
 version = "4.1.1"
@@ -1294,61 +1377,62 @@ files = [
 
 [[package]]
 name = "urllib3"
-version = "1.26.15"
+version = "2.0.2"
 description = "HTTP library with thread-safe connection pooling, file post, and more."
 category = "main"
 optional = false
-python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, !=3.5.*"
+python-versions = ">=3.7"
 files = [
-    {file = "urllib3-1.26.15-py2.py3-none-any.whl", hash = "sha256:aa751d169e23c7479ce47a0cb0da579e3ede798f994f5816a74e4f4500dcea42"},
-    {file = "urllib3-1.26.15.tar.gz", hash = "sha256:8a388717b9476f934a21484e8c8e61875ab60644d29b9b39e11e4b9dc1c6b305"},
+    {file = "urllib3-2.0.2-py3-none-any.whl", hash = "sha256:d055c2f9d38dc53c808f6fdc8eab7360b6fdbbde02340ed25cfbcd817c62469e"},
+    {file = "urllib3-2.0.2.tar.gz", hash = "sha256:61717a1095d7e155cdb737ac7bb2f4324a858a1e2e6466f6d03ff630ca68d3cc"},
 ]
 
 [package.extras]
-brotli = ["brotli (>=1.0.9)", "brotlicffi (>=0.8.0)", "brotlipy (>=0.6.0)"]
-secure = ["certifi", "cryptography (>=1.3.4)", "idna (>=2.0.0)", "ipaddress", "pyOpenSSL (>=0.14)", "urllib3-secure-extra"]
-socks = ["PySocks (>=1.5.6,!=1.5.7,<2.0)"]
+brotli = ["brotli (>=1.0.9)", "brotlicffi (>=0.8.0)"]
+secure = ["certifi", "cryptography (>=1.9)", "idna (>=2.0.0)", "pyopenssl (>=17.1.0)", "urllib3-secure-extra"]
+socks = ["pysocks (>=1.5.6,!=1.5.7,<2.0)"]
+zstd = ["zstandard (>=0.18.0)"]
 
 [[package]]
 name = "virtualenv"
-version = "20.21.0"
+version = "20.23.0"
 description = "Virtual Python Environment builder"
 category = "dev"
 optional = false
 python-versions = ">=3.7"
 files = [
-    {file = "virtualenv-20.21.0-py3-none-any.whl", hash = "sha256:31712f8f2a17bd06234fa97fdf19609e789dd4e3e4bf108c3da71d710651adbc"},
-    {file = "virtualenv-20.21.0.tar.gz", hash = "sha256:f50e3e60f990a0757c9b68333c9fdaa72d7188caa417f96af9e52407831a3b68"},
+    {file = "virtualenv-20.23.0-py3-none-any.whl", hash = "sha256:6abec7670e5802a528357fdc75b26b9f57d5d92f29c5462ba0fbe45feacc685e"},
+    {file = "virtualenv-20.23.0.tar.gz", hash = "sha256:a85caa554ced0c0afbd0d638e7e2d7b5f92d23478d05d17a76daeac8f279f924"},
 ]
 
 [package.dependencies]
 distlib = ">=0.3.6,<1"
-filelock = ">=3.4.1,<4"
-platformdirs = ">=2.4,<4"
+filelock = ">=3.11,<4"
+platformdirs = ">=3.2,<4"
 
 [package.extras]
-docs = ["furo (>=2022.12.7)", "proselint (>=0.13)", "sphinx (>=6.1.3)", "sphinx-argparse (>=0.4)", "sphinxcontrib-towncrier (>=0.2.1a0)", "towncrier (>=22.12)"]
-test = ["covdefaults (>=2.2.2)", "coverage (>=7.1)", "coverage-enable-subprocess (>=1)", "flaky (>=3.7)", "packaging (>=23)", "pytest (>=7.2.1)", "pytest-env (>=0.8.1)", "pytest-freezegun (>=0.4.2)", "pytest-mock (>=3.10)", "pytest-randomly (>=3.12)", "pytest-timeout (>=2.1)"]
+docs = ["furo (>=2023.3.27)", "proselint (>=0.13)", "sphinx (>=6.1.3)", "sphinx-argparse (>=0.4)", "sphinxcontrib-towncrier (>=0.2.1a0)", "towncrier (>=22.12)"]
+test = ["covdefaults (>=2.3)", "coverage (>=7.2.3)", "coverage-enable-subprocess (>=1)", "flaky (>=3.7)", "packaging (>=23.1)", "pytest (>=7.3.1)", "pytest-env (>=0.8.1)", "pytest-freezegun (>=0.4.2)", "pytest-mock (>=3.10)", "pytest-randomly (>=3.12)", "pytest-timeout (>=2.1)", "setuptools (>=67.7.1)", "time-machine (>=2.9)"]
 
 [[package]]
 name = "werkzeug"
-version = "2.2.3"
+version = "2.3.3"
 description = "The comprehensive WSGI web application library."
 category = "dev"
 optional = false
-python-versions = ">=3.7"
+python-versions = ">=3.8"
 files = [
-    {file = "Werkzeug-2.2.3-py3-none-any.whl", hash = "sha256:56433961bc1f12533306c624f3be5e744389ac61d722175d543e1751285da612"},
-    {file = "Werkzeug-2.2.3.tar.gz", hash = "sha256:2e1ccc9417d4da358b9de6f174e3ac094391ea1d4fbef2d667865d819dfd0afe"},
+    {file = "Werkzeug-2.3.3-py3-none-any.whl", hash = "sha256:4866679a0722de00796a74086238bb3b98d90f423f05de039abb09315487254a"},
+    {file = "Werkzeug-2.3.3.tar.gz", hash = "sha256:a987caf1092edc7523edb139edb20c70571c4a8d5eed02e0b547b4739174d091"},
 ]
 
 [package.dependencies]
 MarkupSafe = ">=2.1.1"
 
 [package.extras]
-watchdog = ["watchdog"]
+watchdog = ["watchdog (>=2.3)"]
 
 [metadata]
 lock-version = "2.0"
 python-versions = "^3.10"
-content-hash = "f22518a9d8fc35ac7dceb3bc7d75292dcb16411a3ce150d26934b2b95b272a14"
+content-hash = "e3b4e1e8877abd983a3ffd4c7c1280cd8d1241f7675eb05be4071e965644d8b1"
diff --git a/program/tests/factories.py b/program/tests/factories.py
new file mode 100644
index 00000000..d0987c86
--- /dev/null
+++ b/program/tests/factories.py
@@ -0,0 +1,83 @@
+from datetime import time, timedelta
+
+import factory
+
+from django.contrib.auth.models import User
+from django.utils.timezone import now
+from program.models import (
+    FundingCategory,
+    Host,
+    Image,
+    RRule,
+    Schedule,
+    Show,
+    TimeSlot,
+    Type,
+    UserProfile,
+)
+
+
+class CommonUserFactory(factory.django.DjangoModelFactory):
+    class Meta:
+        model = User
+
+    password = "password"
+    username = factory.Sequence(lambda n: "common_%d" % n)
+
+
+class HostFactory(factory.django.DjangoModelFactory):
+    class Meta:
+        model = Host
+
+    name = factory.Sequence(lambda n: "host %d" % n)
+
+
+class ImageFactory(factory.django.DjangoModelFactory):
+    class Meta:
+        model = Image
+
+
+class FundingCategoryFactory(factory.django.DjangoModelFactory):
+    class Meta:
+        model = FundingCategory
+
+    name = factory.Sequence(lambda n: "funding category %d" % n)
+    slug = factory.Sequence(lambda n: "fc_%d" % n)
+
+
+class TypeFactory(factory.django.DjangoModelFactory):
+    class Meta:
+        model = Type
+
+    name = factory.Sequence(lambda n: "type %d" % n)
+    slug = factory.Sequence(lambda n: "t_%d" % n)
+
+
+class ShowFactory(factory.django.DjangoModelFactory):
+    class Meta:
+        model = Show
+
+    name = factory.Sequence(lambda n: "show %d" % n)
+    slug = factory.Sequence(lambda n: "%s_d" % n)
+
+
+class RRuleFactory(factory.django.DjangoModelFactory):
+    class Meta:
+        model = RRule
+
+
+class ScheduleFactory(factory.django.DjangoModelFactory):
+    class Meta:
+        model = Schedule
+
+    end_time = "03:00:00"
+    first_date = "2023-01-01"
+    start_time = "02:00:00"
+
+
+class TimeslotFactory(factory.django.DjangoModelFactory):
+    class Meta:
+        model = TimeSlot
+
+    end = now() + timedelta(hours=1)
+    start = now()
diff --git a/program/tests/test_schedules.py b/program/tests/test_schedules.py
new file mode 100644
index 00000000..32dabb5c
--- /dev/null
+++ b/program/tests/test_schedules.py
@@ -0,0 +1,220 @@
+from datetime import datetime, timedelta
+
+import pytest
+
+from conftest import assert_data
+from program.models import TimeSlot
+from program.tests.factories import RRuleFactory, ScheduleFactory
+
+pytestmark = pytest.mark.django_db
+
+
+def url(show=None, schedule=None) -> str:
+    if show and schedule:
+        return f"/api/v1/shows/{show.id}/schedules/{schedule.id}/"
+    elif show and not schedule:
+        return f"/api/v1/shows/{show.id}/schedules/"
+    elif not show and schedule:
+        return f"/api/v1/schedules/{schedule.id}/"
+    else:
+        return f"/api/v1/schedules/"
+
+
+def schedule_data(rrule) -> dict[str, dict[str | int]]:
+    now = datetime.now()
+    in_an_hour = now + timedelta(hours=1)
+    in_a_year = now + timedelta(days=365)
+
+    return {
+        "schedule": {
+            "end_time": in_an_hour.strftime("%H:%M:%S"),
+            "first_date": now.strftime("%Y-%m-%d"),
+            "last_date": in_a_year.strftime("%Y-%m-%d"),
+            "rrule": rrule.id,
+            "start_time": now.strftime("%H:%M:%S"),
+        },
+    }
+
+
+def test_create_once_schedule(admin_api_client, once_rrule, show):
+    data = schedule_data(once_rrule)
+
+    response = admin_api_client.post(url(show=show), data=data, format="json")
+
+    assert response.status_code == 201
+    assert TimeSlot.objects.all().count() == 1
+
+    assert_data(response, data)
+
+
+def test_create_daily_schedule(admin_api_client, show):
+    rrule = RRuleFactory(freq=3)
+    data = schedule_data(rrule)
+
+    response = admin_api_client.post(url(show=show), data=data, format="json")
+
+    assert response.status_code == 201
+    assert TimeSlot.objects.all().count() == 365 or 366
+
+    assert_data(response, data)
+
+
+def test_create_business_days_schedule(admin_api_client, show):
+    rrule = RRuleFactory(freq=2, by_weekdays="0,1,2,3,4")
+    data = schedule_data(rrule)
+
+    response = admin_api_client.post(url(show=show), data=data, format="json")
+
+    assert response.status_code == 201
+    assert TimeSlot.objects.all().count() == 261 or 262
+
+    assert_data(response, data)
+
+
+def test_create_weekends_schedule(admin_api_client, show):
+    rrule = RRuleFactory(freq=2, by_weekdays="5,6")
+    data = schedule_data(rrule)
+
+    response = admin_api_client.post(url(show=show), data=data, format="json")
+
+    assert response.status_code == 201
+    assert TimeSlot.objects.all().count() == 104 or 105
+
+    assert_data(response, data)
+
+
+def test_create_weekly_schedule(admin_api_client, show):
+    rrule = RRuleFactory(freq=2)
+    data = schedule_data(rrule)
+
+    response = admin_api_client.post(url(show=show), data=data, format="json")
+
+    assert response.status_code == 201
+    assert TimeSlot.objects.all().count() == 52 or 53
+
+    assert_data(response, data)
+
+
+def test_create_2weekly_schedule(admin_api_client, show):
+    rrule = RRuleFactory(freq=2, interval=2)
+    data = schedule_data(rrule)
+
+    response = admin_api_client.post(url(show=show), data=data, format="json")
+
+    assert response.status_code == 201
+    assert TimeSlot.objects.all().count() == 26 or 27
+
+    assert_data(response, data)
+
+
+def test_create_4weekly_schedule(admin_api_client, show):
+    rrule = RRuleFactory(freq=2, interval=4)
+    data = schedule_data(rrule)
+
+    response = admin_api_client.post(url(show=show), data=data, format="json")
+
+    assert response.status_code == 201
+    assert TimeSlot.objects.all().count() == 13 or 14
+
+    assert_data(response, data)
+
+
+def test_create_monthly_schedule(admin_api_client, show):
+    rrule = RRuleFactory(freq=1)
+    data = schedule_data(rrule)
+
+    response = admin_api_client.post(url(show=show), data=data, format="json")
+
+    assert response.status_code == 201
+    assert TimeSlot.objects.all().count() == 12
+
+    assert_data(response, data)
+
+
+def test_create_2monthly_schedule(admin_api_client, show):
+    rrule = RRuleFactory(freq=1, interval=2)
+    data = schedule_data(rrule)
+
+    response = admin_api_client.post(url(show=show), data=data, format="json")
+
+    assert response.status_code == 201
+    assert TimeSlot.objects.all().count() == 6
+
+    assert_data(response, data)
+
+
+def test_create_3monthly_schedule(admin_api_client, show):
+    rrule = RRuleFactory(freq=1, interval=3)
+    data = schedule_data(rrule)
+
+    response = admin_api_client.post(url(show=show), data=data, format="json")
+
+    assert response.status_code == 201
+    assert TimeSlot.objects.all().count() == 4
+
+    assert_data(response, data)
+
+
+def test_create_4monthly_schedule(admin_api_client, show):
+    rrule = RRuleFactory(freq=1, interval=4)
+    data = schedule_data(rrule)
+
+    response = admin_api_client.post(url(show=show), data=data, format="json")
+
+    assert response.status_code == 201
+    assert TimeSlot.objects.all().count() == 3
+
+    assert_data(response, data)
+
+
+def test_create_schedule_forbidden_for_common_user(common_api_client1, once_rrule, show):
+    data = schedule_data(once_rrule)
+
+    response = common_api_client1.post(url(show=show), data=data, format="json")
+
+    assert response.status_code == 403
+
+
+def test_delete_schedule(admin_api_client, once_schedule):
+    response = admin_api_client.delete(url(schedule=once_schedule))
+
+    assert response.status_code == 204
+
+
+def test_delete_schedule_forbidden_for_common_user(common_api_client1, once_schedule):
+    response = common_api_client1.delete(url(schedule=once_schedule))
+
+    assert response.status_code == 403
+
+
+def test_list_schedules(api_client, once_rrule, show):
+    SCHEDULES = 3
+    ScheduleFactory.create_batch(size=SCHEDULES, show=show, rrule=once_rrule)
+
+    response = api_client.get(url(show=show))
+
+    assert response.status_code == 200
+    assert len(response.data) == 3
+
+
+def test_retrieve_schedule(api_client, once_schedule):
+    response = api_client.get(url(schedule=once_schedule))
+
+    assert response.status_code == 200
+
+
+def test_update_schedule(admin_api_client, once_schedule):
+    update = {
+        "schedule": {
+            "first_date": once_schedule.first_date,
+            "start_time": once_schedule.start_time,
+            "end_time": once_schedule.end_time,
+            "rrule": once_schedule.rrule.id,
+        },
+    }
+
+    response = admin_api_client.put(url(schedule=once_schedule), data=update, format="json")
+
+    assert response.status_code == 200
+
+    assert_data(response, update)
diff --git a/program/tests/test_shows.py b/program/tests/test_shows.py
new file mode 100644
index 00000000..3f997fd0
--- /dev/null
+++ b/program/tests/test_shows.py
@@ -0,0 +1,86 @@
+import pytest
+
+from conftest import assert_data
+from program.tests.factories import ShowFactory
+
+pytestmark = pytest.mark.django_db
+
+
+def url(show=None) -> str:
+    return f"/api/v1/shows/{show.id}/" if show else "/api/v1/shows/"
+
+
+def show_data(funding_category, type_) -> dict[str, str | int]:
+    return {
+        "funding_category": funding_category.id,
+        "name": "NAME",
+        "short_description": "SHORT DESCRIPTION",
+        "slug": "SLUG",
+        "type": type_.id,
+    }
+
+
+def test_create_show(admin_api_client, funding_category, type_):
+    data = show_data(funding_category, type_)
+
+    response = admin_api_client.post(url(), data=data)
+
+    assert response.status_code == 201
+
+    assert_data(response, data)
+
+
+def test_create_show_forbidden_for_common_user(common_api_client1, funding_category, type_):
+    data = show_data(funding_category, type_)
+
+    response = common_api_client1.post(url(), data=data)
+
+    assert response.status_code == 403
+
+
+def test_delete_show(admin_api_client, show):
+    response = admin_api_client.delete(url(show))
+
+    assert response.status_code == 204
+
+
+def test_delete_show_forbidden_for_common_user(common_api_client1, show):
+    response = common_api_client1.delete(url(show))
+
+    assert response.status_code == 403
+
+
+def test_list_shows(api_client):
+    SHOWS = 3
+    ShowFactory.create_batch(size=SHOWS)
+
+    response = api_client.get(url())
+
+    assert response.status_code == 200
+    assert len(response.data) == SHOWS
+
+
+def test_retrieve_show_as_admin_user(admin_api_client, show):
+    response = admin_api_client.get(url(show))
+
+    assert response.status_code == 200
+    assert response.data["id"] == show.id
+    assert "internal_note" in response.data
+
+
+def test_update_show(admin_api_client, funding_category, type_, show):
+    update = show_data(funding_category, type_)
+
+    response = admin_api_client.put(url(show), data=update)
+
+    assert response.status_code == 200
+
+    assert_data(response, update)
+
+
+def test_update_show_forbidden_for_common_user(common_api_client1, funding_category, type_, show):
+    update = show_data(funding_category, type_)
+
+    response = common_api_client1.put(url(show), data=update)
+
+    assert response.status_code == 403
diff --git a/program/tests/test_timeslots.py b/program/tests/test_timeslots.py
new file mode 100644
index 00000000..51489899
--- /dev/null
+++ b/program/tests/test_timeslots.py
@@ -0,0 +1,95 @@
+import pytest
+
+pytestmark = pytest.mark.django_db
+
+
+def url(show=None, schedule=None, timeslot=None) -> str:
+    if show and schedule and timeslot:
+        return f"/api/v1/shows/{show.id}/schedules/{schedule.id}/timeslots/{timeslot.id}/"
+    elif show and schedule:
+        return f"/api/v1/shows/{show.id}/schedules/{schedule.id}/timeslots/"
+    elif show:
+        return f"/api/v1/shows/{show.id}/schedules/"
+    elif timeslot:
+        return f"/api/v1/timeslots/{timeslot.id}/"
+    else:
+        return "/api/v1/timeslots/"
+
+
+def timeslot_data() -> dict[str, str | int]:
+    return {
+        "memo": "MEMO",
+        "playlist_id": 1,
+        "repetition_of": 1,
+    }
+
+
+def test_delete_timeslot_as_admin(admin_api_client, once_timeslot):
+    response = admin_api_client.delete(url(timeslot=once_timeslot))
+
+    assert response.status_code == 204
+
+
+def test_delete_forbidden_as_common_user(common_api_client1, once_timeslot):
+    response = common_api_client1.delete(url(timeslot=once_timeslot))
+
+    assert response.status_code == 403
+
+
+def test_delete_forbidden_as_unauthenticated_user(api_client, once_timeslot):
+    response = api_client.delete(url(timeslot=once_timeslot))
+
+    assert response.status_code == 403
+
+
+def test_retrieve_timeslot(api_client, once_timeslot):
+    response = api_client.get(url(timeslot=once_timeslot))
+
+    assert response.status_code == 200
+
+
+def test_update_memo_as_admin(admin_api_client, once_timeslot):
+    update = {"memo": "MEMO"}
+
+    response = admin_api_client.patch(url(timeslot=once_timeslot), data=update)
+
+    assert response.status_code == 200
+
+
+def test_update_playlist_id_as_admin(admin_api_client, once_timeslot):
+    update = {"playlist_id": 1}
+
+    response = admin_api_client.patch(url(timeslot=once_timeslot), data=update)
+
+    assert response.status_code == 200
+
+
+def test_update_repetition_of_as_admin(admin_api_client, once_timeslot):
+    update = {"repetition_of": 1}
+    response = admin_api_client.patch(url(timeslot=once_timeslot), data=update)
+
+    assert response.status_code == 200
+
+
+def test_update_as_admin(admin_api_client, once_timeslot):
+    update = timeslot_data()
+
+    response = admin_api_client.put(url(timeslot=once_timeslot), data=update)
+
+    assert response.status_code == 200
+
+
+def test_update_forbidden_as_not_owner(common_api_client2, owned_show_once_timeslot):
+    update = timeslot_data()
+
+    response = common_api_client2.put(url(timeslot=owned_show_once_timeslot), data=update)
+
+    assert response.status_code == 403
+
+
+def test_update_timeslot_forbidden_as_unauthenticated(api_client, once_timeslot):
+    update = timeslot_data()
+
+    response = api_client.put(url(timeslot=once_timeslot), data=update)
+
+    assert response.status_code == 403
diff --git a/program/tests/test_users.py b/program/tests/test_users.py
new file mode 100644
index 00000000..d31439d3
--- /dev/null
+++ b/program/tests/test_users.py
@@ -0,0 +1,162 @@
+import pytest
+
+from conftest import assert_data
+from program.tests.factories import CommonUserFactory
+
+pytestmark = pytest.mark.django_db
+
+
+def url(user=None) -> str:
+    return f"/api/v1/users/{user.id}/" if user else "/api/v1/users/"
+
+
+def user_data(is_superuser=False, add_profile=False) -> dict[str, str | dict[str, str]]:
+    data = {
+        "password": "password",
+        "username": "user",
+    }
+
+    if is_superuser:
+        data["is_superuser"] = is_superuser
+
+    if add_profile:
+        data["profile"] = {
+            "cba_username": "CBA USERNAME",
+            "cba_user_token": "CBA USER TOKEN",
+        }
+
+    return data
+
+
+def test_create_user(admin_api_client):
+    data = user_data()
+
+    response = admin_api_client.post(url(), data=data)
+
+    assert response.status_code == 201
+
+    assert_data(response, data)
+
+
+def test_create_superuser(admin_api_client):
+    data = user_data(is_superuser=True)
+
+    response = admin_api_client.post(url(), data=data)
+
+    assert response.status_code == 201
+
+    assert_data(response, data)
+
+
+def test_create_user_unauthorized(common_api_client1):
+    data = user_data()
+
+    response = common_api_client1.post(url(), data=data)
+
+    assert response.status_code == 403
+
+
+def test_list_users_as_common_user(common_user1, common_api_client1):
+    USERS = 3
+    CommonUserFactory.create_batch(size=USERS)
+
+    response = common_api_client1.get(url())
+
+    assert response.status_code == 200
+    assert len(response.data) == 1
+    assert response.data[0]["username"] == common_user1.username
+
+
+def test_list_users_as_other_common_user(common_user1, common_user2, common_api_client2):
+    USERS = 3
+    CommonUserFactory.create_batch(size=USERS)
+
+    response = common_api_client2.get(url())
+
+    assert response.status_code == 200
+    assert len(response.data) == 1
+    assert response.data[0]["username"] == common_user2.username
+
+
+def test_list_users_as_superuser(admin_api_client):
+    USERS = 3
+    CommonUserFactory.create_batch(size=USERS)
+
+    response = admin_api_client.get(url())
+
+    assert response.status_code == 200
+    assert len(response.data) == USERS + 1
+
+
+def test_list_users_unauthenticated(api_client):
+    USERS = 3
+    CommonUserFactory.create_batch(size=USERS)
+
+    response = api_client.get(url())
+
+    assert response.status_code == 200
+    assert len(response.data) == 0
+
+
+def test_retrieve_user_as_common_user(common_user1, common_api_client1):
+    response = common_api_client1.get(url(common_user1))
+
+    assert response.status_code == 200
+    assert response.data["username"] == common_user1.username
+
+
+def test_retrieve_user_not_found_as_other_common_user(common_user1, common_api_client2):
+    response = common_api_client2.get(url(common_user1))
+
+    assert response.status_code == 404
+
+
+def test_retrieve_user_as_superuser(common_user1, admin_api_client):
+    response = admin_api_client.get(url(common_user1))
+
+    assert response.status_code == 200
+    assert response.data["username"] == common_user1.username
+
+
+def test_retrieve_user_not_found_unauthenticated(common_user1, api_client):
+    response = api_client.get(url(common_user1))
+
+    assert response.status_code == 404
+
+
+def test_update_user(common_user1, admin_api_client):
+    update = {
+        "email": "user@aura.radio",
+        "first_name": "FIRST NAME",
+        "last_name": "LAST NAME",
+    }
+
+    response = admin_api_client.patch(url(common_user1), data=update)
+
+    assert response.status_code == 200
+
+    assert_data(response, update)
+
+
+def test_update_user_forbidden_as_common_user(common_user1, common_api_client1):
+    update = {"username": "USERNAME"}
+
+    response = common_api_client1.patch(url(common_user1), data=update)
+
+    assert response.status_code == 403
+
+
+def test_update_user_forbidden_as_other_common_user(common_user1, common_api_client2):
+    update = {"username": "USERNAME"}
+
+    response = common_api_client2.patch(url(common_user1), data=update)
+
+    assert response.status_code == 403
+
+
+def test_update_user_forbidden_unauthenticated(common_user1, api_client):
+    update = {"username": "USERNAME"}
+
+    response = api_client.patch(url(common_user1), data=update)
+
+    assert response.status_code == 403
diff --git a/pyproject.toml b/pyproject.toml
index 3fdf11cc..7937bbc0 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -44,6 +44,16 @@ werkzeug = "^2.2.3"
 [tool.poetry.group.test.dependencies]
 pytest = "^7.1.3"
 pytest-django = "^4.5.2"
+pytest-factoryboy = "^2.5.1"
+
+[tool.pytest.ini_options]
+DJANGO_SETTINGS_MODULE = "steering.settings"
+django_debug_mode = true
+filterwarnings = [
+    "ignore::DeprecationWarning",
+    "ignore::django.utils.deprecation.RemovedInDjango40Warning",
+    "ignore::django.utils.deprecation.RemovedInDjango41Warning"
+]
 
 [tool.black]
 line-length = 99
-- 
GitLab