full passing test suite w. new stripe integration across multiple project nodes; new gameboard django app; stripe in test mode on staging
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed

This commit is contained in:
Disco DeDisco
2026-03-09 01:07:16 -04:00
parent ad0caa7c17
commit bd72135a2f
27 changed files with 397 additions and 33 deletions

View File

@@ -0,0 +1,43 @@
const initWallet = () => {
let stripe, elements;
const addBtn = document.getElementById('id_add_payment_method');
const saveBtn = document.getElementById('id_save_payment_method');
if (!addBtn) return;
const getCsrf = () => document.cookie.match(/csrftoken=([^;]+)/)[1];
addBtn.addEventListener('click', async () => {
const res = await fetch('/dashboard/wallet/setup-intent', {
method: 'POST',
headers: {'X-CSRFToken': getCsrf()},
});
const {client_secret, publishable_key} = await res.json();
stripe = Stripe(publishable_key);
elements = stripe.elements({clientSecret: client_secret});
elements.create('payment').mount('#id_stripe_payment_element');
saveBtn.hidden = false;
});
saveBtn.addEventListener('click', async () => {
const {error, setupIntent} = await stripe.confirmSetup({
elements,
redirect: 'if_required',
});
if (error) { console.error(error); return; }
const res = await fetch('/dashboard/wallet/save-payment-method', {
method: 'POST',
headers: {
'X-CSRFToken': getCsrf(),
'Content-Type': 'application/x-www-form-urlencoded',
},
body: `payment_method_id=${setupIntent.payment_method}`,
});
const {last4, brand} = await res.json();
const pm = document.createElement('div');
pm.textContent = `${brand} ····${last4}`;
document.getElementById('id_payment_methods').appendChild(pm);
});
};
document.addEventListener('DOMContentLoaded', initWallet);

View File

@@ -0,0 +1,79 @@
from unittest import mock
from django.test import TestCase
from apps.lyric.models import PaymentMethod, User
class SetupIntentViewTest(TestCase):
def setUp(self):
self.user = User.objects.create(email="capman@test.io")
self.client.force_login(self.user)
def test_setup_intent_requires_login(self):
self.client.logout()
response = self.client.post("/dashboard/wallet/setup-intent")
self.assertRedirects(
response, "/?next=/dashboard/wallet/setup-intent",
fetch_redirect_response=False,
)
@mock.patch("apps.dashboard.views.stripe")
def test_returns_client_secret(self, mock_stripe):
mock_stripe.Customer.create.return_value = mock.Mock(id="cus_test123")
mock_stripe.SetupIntent.create.return_value = mock.Mock(client_secret="seti_secret")
response = self.client.post("/dashboard/wallet/setup-intent")
self.assertEqual(response.status_code, 200)
self.assertEqual(response.json()["client_secret"], "seti_secret")
self.assertIn("publishable_key", response.json())
@mock.patch("apps.dashboard.views.stripe")
def test_reuses_existing_stripe_customer(self, mock_stripe):
self.user.stripe_customer_id = "cus_existing"
self.user.save()
mock_stripe.SetupIntent.create.return_value = mock.Mock(client_secret="seti_secret")
self.client.post("/dashboard/wallet/setup-intent")
mock_stripe.Customer.create.assert_not_called()
mock_stripe.SetupIntent.create.assert_called_once_with(customer="cus_existing")
class SavePaymentMethodViewTest(TestCase):
def setUp(self):
self.user = User.objects.create(email="capman@test.io")
self.user.stripe_customer_id = "cus_test123"
self.user.save()
self.client.force_login(self.user)
def test_save_payment_method_requires_login(self):
self.client.logout()
response = self.client.post(
"/dashboard/wallet/save-payment-method", {"payment_method_id": "pm_test"}
)
self.assertRedirects(
response, "/?next=/dashboard/wallet/save-payment-method",
fetch_redirect_response=False,
)
@mock.patch("apps.dashboard.views.stripe")
def test_creates_payment_method_record(self, mock_stripe):
mock_stripe.PaymentMethod.retrieve.return_value = mock.Mock(
card=mock.Mock(last4="4242", brand="visa")
)
self.client.post(
"/dashboard/wallet/save-payment-method",
{"payment_method_id": "pm_test123"},
)
pm = PaymentMethod.objects.get(user=self.user)
self.assertEqual(pm.last4, "4242")
self.assertEqual(pm.brand, "visa")
@mock.patch("apps.dashboard.views.stripe")
def test_returns_json_with_last4_and_brand(self, mock_stripe):
mock_stripe.PaymentMethod.retrieve.return_value = mock.Mock(
card=mock.Mock(last4="4242", brand="visa")
)
response = self.client.post(
"/dashboard/wallet/save-payment-method",
{"payment_method_id": "pm_test123"},
)
data = response.json()
self.assertEqual(data["last4"], "4242")
self.assertEqual(data["brand"], "visa")

View File

@@ -10,4 +10,6 @@ urlpatterns = [
path('users/<uuid:user_id>/', views.my_lists, name='my_lists'),
path('toggle_applets', views.toggle_applets, name="toggle_applets"),
path('wallet/', views.wallet, name='wallet'),
path('wallet/setup-intent', views.setup_intent, name='setup_intent'),
path('wallet/save-payment-method', views.save_payment_method, name='save_payment_method'),
]

View File

@@ -1,12 +1,16 @@
import stripe
from django.conf import settings
from django.contrib import messages
from django.contrib.auth.decorators import login_required
from django.db.models import Max, Q
from django.http import HttpResponse, HttpResponseForbidden
from django.http import HttpResponse, HttpResponseForbidden, JsonResponse
from django.shortcuts import redirect, render
from django.views.decorators.csrf import ensure_csrf_cookie
from apps.dashboard.forms import ExistingListItemForm, ItemForm
from apps.dashboard.models import Applet, Item, List, UserApplet
from apps.lyric.models import Token, User, Wallet
from apps.lyric.models import PaymentMethod, Token, User, Wallet
APPLET_ORDER = ["wallet", "new-list", "my-lists", "username", "palette"]
@@ -146,6 +150,7 @@ def toggle_applets(request):
return redirect("home")
@login_required(login_url="/")
@ensure_csrf_cookie
def wallet(request):
wallet = request.user.wallet
coin = request.user.tokens.filter(token_type=Token.COIN).first()
@@ -155,3 +160,31 @@ def wallet(request):
"coin": coin,
"free_tokens": free_tokens,
})
@login_required(login_url="/")
def setup_intent(request):
stripe.api_key = settings.STRIPE_SECRET_KEY
user = request.user
if not user.stripe_customer_id:
customer = stripe.Customer.create(email=user.email)
user.stripe_customer_id = customer.id
user.save(update_fields=["stripe_customer_id"])
intent = stripe.SetupIntent.create(customer=user.stripe_customer_id)
return JsonResponse({
"client_secret": intent.client_secret,
"publishable_key": settings.STRIPE_PUBLISHABLE_KEY,
})
@login_required(login_url="/")
def save_payment_method(request):
stripe.api_key = settings.STRIPE_SECRET_KEY
pm_id = request.POST.get("payment_method_id")
pm = stripe.PaymentMethod.retrieve(pm_id)
stripe.PaymentMethod.attach(pm_id, customer=request.user.stripe_customer_id)
PaymentMethod.objects.create(
user=request.user,
stripe_pm_id=pm_id,
last4=pm.card.last4,
brand=pm.card.brand,
)
return JsonResponse({"last4": pm.card.last4, "brand": pm.card.brand})

View File

View File

@@ -0,0 +1,3 @@
from django.contrib import admin
# Register your models here.

View File

@@ -0,0 +1,5 @@
from django.apps import AppConfig
class GameboardConfig(AppConfig):
name = 'apps.gameboard'

View File

@@ -0,0 +1,3 @@
from django.db import models
# Create your models here.

View File

View File

@@ -0,0 +1,52 @@
import lxml.html
from django.test import TestCase
from apps.lyric.models import User
class GameboardViewTest(TestCase):
def setUp(self):
self.user = User.objects.create(email="capman@test.io")
self.client.force_login(self.user)
response = self.client.get("/gameboard/")
self.parsed = lxml.html.fromstring(response.content)
def test_gameboard_requires_login(self):
self.client.logout()
response = self.client.get("/gameboard/")
self.assertRedirects(
response, "/?next=/gameboard/", fetch_redirect_response=False
)
def test_gameboard_renders(self):
response = self.client.get("/gameboard/")
self.assertEqual(response.status_code, 200)
def test_gameboard_shows_my_games_applet(self):
[_] = self.parsed.cssselect("#id_applet_my_games")
def test_gameboard_shows_new_game_applet(self):
[_] = self.parsed.cssselect("#id_applet_new_game")
def test_gameboard_shows_game_kit_btn(self):
[_] = self.parsed.cssselect("#id_game_kit_btn")
def test_gameboard_shows_game_gear(self):
[_] = self.parsed.cssselect("#id_game_gear")
def test_my_games_has_no_game_items_for_new_user(self):
game_items = self.parsed.cssselect("#id_applet_my_games .game-item")
self.assertEqual(len(game_items), 0)
def test_game_kit_has_coin_on_a_string(self):
[_] = self.parsed.cssselect("#id_game_kit #id_kit_coin_on_a_string")
def test_game_kit_has_free_token(self):
[_] = self.parsed.cssselect("#id_game_kit #id_kit_free_token_0")
def test_game_kit_has_card_deck_placeholder(self):
[_] = self.parsed.cssselect("#id_game_kit #id_kit_card_deck")
def test_game_kit_has_dice_set_placeholder(self):
[_] = self.parsed.cssselect("#id_game_kit #id_kit_dice_set")

View File

@@ -0,0 +1,9 @@
from django.urls import path
from . import views
urlpatterns = [
path('', views.gameboard, name='gameboard'),
]

View File

@@ -0,0 +1,16 @@
from django.contrib.auth.decorators import login_required
from django.shortcuts import render
from apps.lyric.models import Token
@login_required(login_url="/")
def gameboard(request):
coin = request.user.tokens.filter(token_type=Token.COIN).first()
free_tokens = list(request.user.tokens.filter(token_type=Token.FREE))
return render(
request, "apps/gameboard/gameboard.html", {
"coin": coin,
"free_tokens": free_tokens,
}
)

View File

@@ -1,6 +1,6 @@
from django.contrib import admin
from .models import LoginToken, User
from .models import LoginToken, Token, User
class UserAdmin(admin.ModelAdmin):
@@ -9,3 +9,4 @@ class UserAdmin(admin.ModelAdmin):
admin.site.register(User, UserAdmin)
admin.site.register(LoginToken)
admin.site.register(Token)

View File

@@ -0,0 +1,30 @@
# Generated by Django 6.0 on 2026-03-08 20:26
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('lyric', '0006_token_wallet'),
]
operations = [
migrations.AddField(
model_name='user',
name='stripe_customer_id',
field=models.CharField(blank=True, max_length=255, null=True),
),
migrations.CreateModel(
name='PaymentMethod',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('stripe_pm_id', models.CharField(max_length=255)),
('last4', models.CharField(max_length=4)),
('brand', models.CharField(max_length=32)),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='payment_methods', to=settings.AUTH_USER_MODEL)),
],
),
]

View File

@@ -31,6 +31,7 @@ class User(AbstractBaseUser):
username = models.CharField(max_length=35, unique=True, null=True, blank=True)
searchable = models.BooleanField(default=False)
palette = models.CharField(max_length=32, default="palette-default")
stripe_customer_id = models.CharField(max_length=255, null=True, blank=True)
is_staff = models.BooleanField(default=False)
is_superuser = models.BooleanField(default=False)
@@ -78,6 +79,15 @@ class Token(models.Model):
)
return self.get_token_type_display()
class PaymentMethod(models.Model):
user = models.ForeignKey(User, on_delete=models.CASCADE, related_name="payment_methods")
stripe_pm_id = models.CharField(max_length=255)
last4 = models.CharField(max_length=4)
brand = models.CharField(max_length=32)
def __str__(self):
return f"{self.brand} ....{self.last4}"
@receiver(post_save, sender=User)
def create_wallet_and_tokens(sender, instance, created, **kwargs):
if not created: