--- /dev/null
+import json
+import subprocess
+from unittest import mock
+
+import pytest
+
+from .utils import create_user, create_user_and_song
+
+import littlesongplace as lsp
+
+SUBDATA = json.dumps({
+    "endpoint": "https://example.com",
+    "expirationTime": None,
+    "keys": {"auth": "auth_str", "p256dh": "p256dh_str"}
+})
+
+@pytest.fixture
+def subid(client, user):
+    response = client.post(
+            "/push-notifications/subscribe",
+            headers={"Content-Type": "application/json"},
+            data=SUBDATA)
+    yield response.json["subid"]
+
+@pytest.fixture
+def vapid_keys(app):
+    with open(lsp.datadir.get_vapid_public_key_path(), "w") as keyfile:
+        keyfile.write("vapid-public-key")
+    with open(lsp.datadir.get_vapid_private_key_path(), "w") as keyfile:
+        keyfile.write("vapid-private-key")
+
+################################################################################
+# Subscribe
+
+def test_subscribe_new(client, user):
+    response = client.post(
+            "/push-notifications/subscribe",
+            headers={"Content-Type": "application/json"},
+            data=SUBDATA)
+    assert response.json["status"] == "success"
+    assert response.json["subid"] == 1
+
+def test_subscribe_update_existing(client, user):
+    # Create new
+    for i in range(1, 4):
+        response = client.post(
+                "/push-notifications/subscribe",
+                headers={"Content-Type": "application/json"},
+                data=SUBDATA)
+        assert response.json["subid"] == i
+
+    # Update subid 1
+    response = client.post(
+            "/push-notifications/subscribe?subid=1",
+            headers={"Content-Type": "application/json"},
+            data=SUBDATA)
+
+    assert response.json["status"] == "success"
+    assert response.json["subid"] == 1
+
+def test_subscribe_not_logged_in(client):
+    response = client.post(
+            "/push-notifications/subscribe",
+            headers={"Content-Type": "application/json"},
+            data=SUBDATA)
+    assert response.status_code == 302
+    assert response.headers["Location"] == "/login"
+
+def test_subscribe_other_users_subid(client, user):
+    # Create subid1 for user
+    response = client.post(
+            "/push-notifications/subscribe",
+            headers={"Content-Type": "application/json"},
+            data=SUBDATA)
+
+    # Try to modify as user2
+    create_user(client, "user2", login=True)
+    response = client.post(
+            "/push-notifications/subscribe?subid=1",
+            headers={"Content-Type": "application/json"},
+            data=SUBDATA)
+
+    # Subid ignored, created new one
+    assert response.json["subid"] == 2
+
+def test_subscribe_other_users_subid(client, user):
+    # Create subid1 for user, requesting invalid subid
+    response = client.post(
+            "/push-notifications/subscribe?subid=100",
+            headers={"Content-Type": "application/json"},
+            data=SUBDATA)
+
+    # Subid ignored, created new one
+    assert response.json["subid"] == 1
+
+################################################################################
+# VAPID Key
+
+def test_vapid_public_key(client, vapid_keys):
+    response = client.get("/push-notifications/vapid-public-key")
+    assert response.json["status"] == "ok"
+    assert response.json["public_key"] == "vapid-public-key"
+
+def test_vapid_public_key_no_key(client):
+    response = client.get("/push-notifications/vapid-public-key")
+    assert response.json["status"] == "error"
+
+################################################################################
+# Get Settings
+
+def test_get_settings_defaults(client, user, subid):
+    response = client.get(f"/push-notifications/settings?subid={subid}")
+    assert response.json["comments"] == False
+    assert response.json["songs"] == False
+
+def test_get_settings_not_logged_in(client):
+    response = client.get("/push-notifications/settings?subid=1")
+    assert response.status_code == 302
+    assert response.headers["Location"] == "/login"
+
+def test_get_settings_invalid_subid(client, user):
+    response = client.get("/push-notifications/settings?subid=1")
+    assert response.status_code == 404
+
+def test_get_settings_other_users_subid(client, user, subid):
+    create_user(client, "user2", login=True)
+    response = client.get(f"/push-notifications/settings?subid={subid}")
+    assert response.status_code == 404
+
+################################################################################
+# Update Settings
+
+def test_update_settings_ok(client, user, subid):
+    # Update
+    response = client.post(
+            "/push-notifications/settings",
+            headers={"Content-Type": "application/json"},
+            data=json.dumps({"subid": subid, "comments": True, "songs": True}))
+    assert response.json["status"] == "success"
+
+    # Verify settings applied
+    response = client.get(f"/push-notifications/settings?subid={subid}")
+    assert response.json["comments"] == True
+    assert response.json["songs"] == True
+    
+def test_update_settings_not_logged_in(client):
+    response = client.post(
+            "/push-notifications/settings",
+            headers={"Content-Type": "application/json"},
+            data=json.dumps({"subid": 1, "comments": True, "songs": True}))
+    assert response.status_code == 302
+    assert response.headers["Location"] == "/login"
+
+def test_update_settings_invalid_subid(client, user):
+    response = client.post(
+            "/push-notifications/settings",
+            headers={"Content-Type": "application/json"},
+            data=json.dumps({"subid": 1, "comments": True, "songs": True}))
+    assert response.status_code == 404
+
+def test_update_settings_other_users_subid(client, user, subid):
+    create_user(client, "user2", login=True)
+    response = client.post(
+            "/push-notifications/settings",
+            headers={"Content-Type": "application/json"},
+            data=json.dumps({"subid": subid, "comments": True, "songs": True}))
+    assert response.status_code == 404
+
+def test_update_settings_missing_value(client, user, subid):
+    response = client.post(
+            "/push-notifications/settings",
+            headers={"Content-Type": "application/json"},
+            data=json.dumps({"subid": subid, "comments": True}))  # Missing songs
+    assert response.status_code == 400
+
+    response = client.post(
+            "/push-notifications/settings",
+            headers={"Content-Type": "application/json"},
+            data=json.dumps({"subid": subid, "songs": True}))  # Missing comments
+    assert response.status_code == 400
+
+################################################################################
+# Notification delivery
+
+def _enable_notifications(client, subid, **kwargs):
+    response = client.post(
+            "/push-notifications/settings",
+            headers={"Content-Type": "application/json"},
+            data=json.dumps(dict(subid=subid, **kwargs)))
+    assert response.status_code == 200
+
+@mock.patch("pywebpush.webpush")
+def test_notification_for_new_songs_enabled(pushmock, app, client, user, subid, vapid_keys):
+    _enable_notifications(client, subid, comments=False, songs=True)
+    create_user_and_song(client, "user2")
+
+    with app.app_context():
+        lsp.push_notifications.notify_new_songs_cmd([], standalone_mode=False)
+        lsp.push_notifications.wait_all()
+
+    pushmock.assert_called_once_with(
+        json.loads(SUBDATA),
+        '{"title": "New song from user2", "body": null, "url": "/"}',
+        vapid_private_key="vapid-private-key",
+        vapid_claims={"sub": "mailto:littlesongplace@gmail.com"})
+
+@mock.patch("pywebpush.webpush")
+def test_notification_for_new_songs_disabled(pushmock, app, client, user, subid, vapid_keys):
+    _enable_notifications(client, subid, comments=False, songs=False)
+    create_user_and_song(client, "user2")
+
+    with app.app_context():
+        lsp.push_notifications.notify_new_songs_cmd([], standalone_mode=False)
+        lsp.push_notifications.wait_all()
+
+    pushmock.assert_not_called()
+
+# notification for activity when enabled
+# no notification for activity when disabled
+
+# notification for jam when enabled
+# no notification for jam when disabled
+
+
+
+################################################################################
+# Subscription expiry
+
+# @mock.patch("pywebpush.webpush")
+# def test_subscription_deleted_on_410(client, user, subid):
+#     ...
+
+# subscription deleted on 410
+# subscription not deleted on 500