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:

View File

@@ -55,9 +55,12 @@ INSTALLED_APPS = [
'django.contrib.sessions',
'django.contrib.messages',
'django.contrib.staticfiles',
# Custom apps
# Board apps
'apps.dashboard',
'apps.gameboard',
# Gamer apps
'apps.lyric',
# Custom apps
'apps.api',
'functional_tests',
# Depend apps
@@ -196,4 +199,8 @@ LOGGING = {
# Mailgun API settings (for HTTP API instead of SMTP)
MAILGUN_API_KEY = os.environ.get("MAILGUN_API_KEY")
MAILGUN_DOMAIN = "howdy.earthmanrpg.com" # Your Mailgun domain
MAILGUN_DOMAIN = "howdy.earthmanrpg.com" # Your Mailgun domain
# Stripe payment settings
STRIPE_PUBLISHABLE_KEY = os.environ.get("STRIPE_PUBLISHABLE_KEY", "")
STRIPE_SECRET_KEY = os.environ.get("STRIPE_SECRET_KEY", "")

View File

@@ -11,6 +11,7 @@ urlpatterns = [
path('api/', include('apps.api.urls')),
path('dashboard/', include('apps.dashboard.urls')),
path('lyric/', include('apps.lyric.urls')),
path('gameboard/', include('apps.gameboard.urls')),
]
# Please remove the following urlpattern

View File

@@ -104,6 +104,12 @@ class WalletDisplayTest(FunctionalTest):
By.CSS_SELECTOR, "#id_stripe_payment_element iframe"
)
self.browser.switch_to.frame(stripe_frame)
# 4a. Wait for card inputs to render inside iframe
self.wait_for(
lambda: self.browser.find_element(
By.CSS_SELECTOR, 'input[placeholder="1234 1234 1234 1234"]'
)
)
# 5. Fill in Stripe test card details
self.browser.find_element(
By.CSS_SELECTOR, 'input[placeholder="1234 1234 1234 1234"]'
@@ -115,7 +121,7 @@ class WalletDisplayTest(FunctionalTest):
By.CSS_SELECTOR, 'input[placeholder="CVC"]'
).send_keys("424")
self.browser.find_element(
By.CSS_SELECTOR, 'input[placeholder="ZIP"]'
By.CSS_SELECTOR, 'input[placeholder="12345"]'
).send_keys("42424")
# 6. Return to main doc & submit form
self.browser.switch_to.default_content()

View File

@@ -5,6 +5,12 @@ import sys
def main():
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'core.settings')
try:
from dotenv import load_dotenv
load_dotenv()
except ImportError:
pass
"""Run administrative tasks."""
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'core.settings')
try:

View File

@@ -1,4 +1,5 @@
{% extends "core/base.html" %}
{% load static %}
{% block content %}
<div class="wallet-page">
@@ -27,6 +28,7 @@
<button id="id_add_payment_method">Add Payment Method</button>
<div id="id_stripe_payment_element"></div>
<button id="id_save_payment_method" hidden>Save Card</button>
</section>
<section id="id_tithe_token_shop">
@@ -38,4 +40,6 @@
</div>
</section>
</div>
<script src="https://js.stripe.com/v3/"></script>
<script src="{% static "apps/scripts/wallet.js" %}"></script>
{% endblock content %}

View File

@@ -0,0 +1,35 @@
{% extends "core/base.html" %}
{% block content %}
<div class="gameboard-page">
<section id="id_applet_my_games">
<h2>My Games</h2>
</section>
<section id="id_applet_new_game">
<h2>New Game</h2>
</section>
<div id="id_game_gear"></div>
<button
id="id_game_kit_btn"
onclick="document.getElementById('id_game_kit').style.display='block'"
>
Game Kit
</button>
<div id="id_game_kit" style="display:none;">
{% if coin %}
<div id="id_kit_coin_on_a_string" class="token">
<span class="token-tooltip">{{ coin.tooltip_text }}</span>
</div>
{% endif %}
{% for token in free_tokens %}
<div id="id_kit_free_token_{{ forloop.counter0 }}" class="token">
<span class="token-tooltip">{{ token.tooltip_text }}</span>
</div>
{% endfor %}
<div id="id_kit_card_deck" class="kit-item">Card Deck</div>
<div id="id_kit_dice_set" class="kit-item">Dice Set</div>
</div>
</div>
{% endblock content %}