LGTM (40 Part Series)
1 LGTM Devlog 0: Teaching Git through playing a game
2 LGTM Devlog 1: New Logo, and jumping the gun by buying a domain
… 36 more parts…
3 LGTM Devlog 2: Design Ideas for the game I
4 LGTM Devlog 3: Design Ideas for the game 2
5 LGTM Devlog 4: Technical Decisions – GitHub API and Python
6 LGTM Devlog 5: Technical Decisions – Serverless Architecture
7 LGTM Devlog 6: Sprint 1 plans
8 LGTM Devlog 7: Creating a Firebase project
9 LGTM Devlog 8: Starting a git repo for an open source project from scratch
10 LGTM Devlog 9: Python Google Cloud Functions with Unit Tests and Linting
11 LGTM Devlog 10: Capturing the GitHub webhook for fork requests
12 LGTM Devlog 11: Writing the Serverless Function for receiving GitHub webhooks with Pydantic validation
13 LGTM Devlog 12: CI/CD with GitHub Actions to run Unit Tests and deploy Firebase Functions
14 LGTM Devlog 13: GitHub Branch Protection and Security
15 LGTM Devlog 14: Sprint 1 Retrospective
16 LGTM Devlog 15: Sprint 2 plans
17 LGTM Devlog 16: A serverless data base access rule conundrum
18 LGTM Devlog 17: Website and GitHub OAuth
19 LGTM Devlog 18: Python Serverless functions using GitHub API to validate users
20 LGTM Devlog 19: Game data/quest storage
21 LGTM Devlog 20: Python Abstract Base Class-based data/quest storage
22 LGTM Devlog 21: Deploying Pub/Sub-triggered Python Google Cloud Functions
23 LGTM Devlog 22: Modularization
24 LGTM Devlog 23: Sprint 2 Retrospective
25 LGTM Devlog 24: Sprint 3 plans
26 LGTM Devlog 25: Some cleanup
27 LGTM Devlog 26: Python Graphlib DAGs for Quest Stages
28 LGTM Devlog 27: Branching quests
29 LGTM Devlog 28: Game event loop using Google Cloud Scheduler and PubSub
30 LGTM Devlog 29: ORM for Firestore and __init__subclass__ dunders and metaclasses
31 LGTM Devlog 30: Sprint 3 Retrospective
32 LGTM Devlog 31: Sprint 4 Plan
33 LGTM Devlog 32: Secrets Management to avoid storing API keys in services
34 LGTM Devlog 33: Using PyGithub to post GitHub issues and comments
35 LGTM Devlog 34: Characters Posting on GitHub Issues
36 LGTM Devlog 35: Responding to the player’s answers on GitHub Issues Comments
37 LGTM Devlog 36: Character profiles! On GitHub
38 LGTM Devlog 37: Sprint 4 Retrospective
39 LGTM Devlog 38: Sprint 5 Plan
40 LGTM Devlog 39: Planning the story
With the quest storage structure adequately defined, it’s now time to hook up the game creation endpoint, which is part of a trio of cloud functions involved in handling new users:
- GitHub webhook listener – listens for webhook activity on GitHub. This function is specific to GitHub. And triggers a new game if it receives one
- Auth flow – a function triggered by the frontend when a user logs in/authenticates, and should attach any games to their account if not already
- Create new game – a function that will create new game data and kick off the first quest. This is triggered by the webhook listener
Since we need a function to trigger another function internally, we can use Google Cloud’s pub/sub triggers, which are internal. Though we could also use authenticated HTTP functions, we can switch this later if pub/sub turns out to be the wrong choice. The code matching this post can be found at commit c9074d0
Payload validation
As usual, I will make use of pydantic to perform payload validation, with a model defined, but with a twist: pubsub data comes over base64-encoded, so I’ve sub-classed pydantic’s BaseModel
to include a couple of class methods that deal with base64 data, and even specifically consuming the event
structure that comes to us from the pub/sub trigger runtime
<span>from</span> <span>typing</span> <span>import</span> <span>Optional</span><span>from</span> <span>base64</span> <span>import</span> <span>b64decode</span><span>from</span> <span>pydantic</span> <span>import</span> <span>BaseModel</span><span>,</span> <span>Field</span> <span># pylint: disable=no-name-in-module </span><span># pylint: disable=too-few-public-methods,missing-class-docstring </span><span>class</span> <span>BaseModelWithPubSub</span><span>(</span><span>BaseModel</span><span>):</span><span>""" Extra decode functions for pubsub data """</span><span>@</span><span>classmethod</span><span>def</span> <span>from_base64</span><span>(</span><span>cls</span><span>,</span> <span>data</span><span>:</span> <span>bytes</span><span>):</span><span>return</span> <span>cls</span><span>.</span><span>parse_raw</span><span>(</span><span>b64decode</span><span>(</span><span>data</span><span>).</span><span>decode</span><span>(</span><span>"utf-8"</span><span>))</span><span>@</span><span>classmethod</span><span>def</span> <span>from_event</span><span>(</span><span>cls</span><span>,</span> <span>event</span><span>:</span> <span>dict</span><span>):</span><span>return</span> <span>cls</span><span>.</span><span>from_base64</span><span>(</span><span>event</span><span>[</span><span>"data"</span><span>])</span><span>class</span> <span>NewGameData</span><span>(</span><span>BaseModelWithPubSub</span><span>):</span><span>""" Create a new game """</span><span>source</span><span>:</span> <span>str</span> <span>=</span> <span>Field</span><span>(...,</span> <span>title</span><span>=</span><span>"User source, e.g. 'github'"</span><span>)</span><span>userId</span><span>:</span> <span>str</span> <span>=</span> <span>Field</span><span>(...,</span> <span>title</span><span>=</span><span>"User ID"</span><span>)</span><span>userUid</span><span>:</span> <span>Optional</span><span>[</span><span>str</span><span>]</span> <span>=</span> <span>Field</span><span>(</span><span>None</span><span>,</span> <span>title</span><span>=</span><span>"Auth UID if known"</span><span>)</span><span>forkUrl</span><span>:</span> <span>str</span> <span>=</span> <span>Field</span><span>(...,</span> <span>title</span><span>=</span><span>"URL of the LGTM fork"</span><span>)</span><span>from</span> <span>typing</span> <span>import</span> <span>Optional</span> <span>from</span> <span>base64</span> <span>import</span> <span>b64decode</span> <span>from</span> <span>pydantic</span> <span>import</span> <span>BaseModel</span><span>,</span> <span>Field</span> <span># pylint: disable=no-name-in-module </span> <span># pylint: disable=too-few-public-methods,missing-class-docstring </span><span>class</span> <span>BaseModelWithPubSub</span><span>(</span><span>BaseModel</span><span>):</span> <span>""" Extra decode functions for pubsub data """</span> <span>@</span><span>classmethod</span> <span>def</span> <span>from_base64</span><span>(</span><span>cls</span><span>,</span> <span>data</span><span>:</span> <span>bytes</span><span>):</span> <span>return</span> <span>cls</span><span>.</span><span>parse_raw</span><span>(</span><span>b64decode</span><span>(</span><span>data</span><span>).</span><span>decode</span><span>(</span><span>"utf-8"</span><span>))</span> <span>@</span><span>classmethod</span> <span>def</span> <span>from_event</span><span>(</span><span>cls</span><span>,</span> <span>event</span><span>:</span> <span>dict</span><span>):</span> <span>return</span> <span>cls</span><span>.</span><span>from_base64</span><span>(</span><span>event</span><span>[</span><span>"data"</span><span>])</span> <span>class</span> <span>NewGameData</span><span>(</span><span>BaseModelWithPubSub</span><span>):</span> <span>""" Create a new game """</span> <span>source</span><span>:</span> <span>str</span> <span>=</span> <span>Field</span><span>(...,</span> <span>title</span><span>=</span><span>"User source, e.g. 'github'"</span><span>)</span> <span>userId</span><span>:</span> <span>str</span> <span>=</span> <span>Field</span><span>(...,</span> <span>title</span><span>=</span><span>"User ID"</span><span>)</span> <span>userUid</span><span>:</span> <span>Optional</span><span>[</span><span>str</span><span>]</span> <span>=</span> <span>Field</span><span>(</span><span>None</span><span>,</span> <span>title</span><span>=</span><span>"Auth UID if known"</span><span>)</span> <span>forkUrl</span><span>:</span> <span>str</span> <span>=</span> <span>Field</span><span>(...,</span> <span>title</span><span>=</span><span>"URL of the LGTM fork"</span><span>)</span>from typing import Optional from base64 import b64decode from pydantic import BaseModel, Field # pylint: disable=no-name-in-module # pylint: disable=too-few-public-methods,missing-class-docstring class BaseModelWithPubSub(BaseModel): """ Extra decode functions for pubsub data """ @classmethod def from_base64(cls, data: bytes): return cls.parse_raw(b64decode(data).decode("utf-8")) @classmethod def from_event(cls, event: dict): return cls.from_base64(event["data"]) class NewGameData(BaseModelWithPubSub): """ Create a new game """ source: str = Field(..., title="User source, e.g. 'github'") userId: str = Field(..., title="User ID") userUid: Optional[str] = Field(None, title="Auth UID if known") forkUrl: str = Field(..., title="URL of the LGTM fork")
Enter fullscreen mode Exit fullscreen mode
Endpoint
So that leaves the endpoint looking like this, it does the following:
- Decode the event payload
- Create a game structure in Firestore. Right now it only has the user-identification data, we’ll add to it as we build game logic
- Create the start quest as well, using the fancy new Quest object we created to generate the data for a new game, OR having it load any existing one and save a new version. The anticipation here is if we have to deal with any quest version updates, we can let the quest object deal with it if we need to.
<span>def</span> <span>create_new_game</span><span>(</span><span>event</span><span>:</span> <span>dict</span><span>,</span> <span>context</span><span>:</span> <span>Context</span><span>):</span><span>""" Create a new game """</span><span>logger</span><span>.</span><span>info</span><span>(</span><span>"Got create new game request"</span><span>,</span> <span>payload</span><span>=</span><span>event</span><span>)</span><span># decode event </span> <span>try</span><span>:</span><span>new_game_data</span> <span>=</span> <span>NewGameData</span><span>.</span><span>from_event</span><span>(</span><span>event</span><span>)</span><span>except</span> <span>ValidationError</span> <span>as</span> <span>err</span><span>:</span><span>logger</span><span>.</span><span>error</span><span>(</span><span>"Validation error"</span><span>,</span> <span>err</span><span>=</span><span>err</span><span>)</span><span>raise</span> <span>err</span><span>logger</span><span>.</span><span>info</span><span>(</span><span>"Resolved data"</span><span>,</span> <span>new_game_data</span><span>=</span><span>new_game_data</span><span>)</span><span># create game if doesn't exist </span> <span>game_id</span> <span>=</span> <span>create_game_id</span><span>(</span><span>new_game_data</span><span>.</span><span>source</span><span>,</span> <span>new_game_data</span><span>.</span><span>userId</span><span>)</span><span>game_ref</span> <span>=</span> <span>db</span><span>.</span><span>collection</span><span>(</span><span>"game"</span><span>).</span><span>document</span><span>(</span><span>game_id</span><span>)</span><span>game</span> <span>=</span> <span>game_ref</span><span>.</span><span>get</span><span>()</span><span>if</span> <span>game</span><span>.</span><span>exists</span><span>:</span><span>logger</span><span>.</span><span>info</span><span>(</span><span>"Game already exists"</span><span>,</span> <span>game_id</span><span>=</span><span>game_id</span><span>)</span><span>game_ref</span><span>.</span><span>set</span><span>(</span><span>{</span><span>**</span><span>new_game_data</span><span>.</span><span>dict</span><span>(),</span><span>},</span><span>merge</span><span>=</span><span>True</span><span>,</span><span>)</span><span>else</span><span>:</span><span>logger</span><span>.</span><span>info</span><span>(</span><span>"Creating new game"</span><span>,</span> <span>game_id</span><span>=</span><span>game_id</span><span>)</span><span>game_ref</span><span>.</span><span>set</span><span>(</span><span>{</span><span>**</span><span>new_game_data</span><span>.</span><span>dict</span><span>(),</span><span>"joined"</span><span>:</span> <span>firestore</span><span>.</span><span>SERVER_TIMESTAMP</span><span>,</span><span>}</span><span>)</span><span># create starting quest if not exist </span> <span>FirstQuest</span> <span>=</span> <span>get_quest_by_name</span><span>(</span><span>FIRST_QUEST_NAME</span><span>)</span><span>quest_obj</span> <span>=</span> <span>FirstQuest</span><span>()</span><span>quest_id</span> <span>=</span> <span>create_quest_id</span><span>(</span><span>game_id</span><span>,</span> <span>FIRST_QUEST_NAME</span><span>)</span><span>quest_ref</span> <span>=</span> <span>db</span><span>.</span><span>collection</span><span>(</span><span>"quest"</span><span>).</span><span>document</span><span>(</span><span>quest_id</span><span>)</span><span>quest</span> <span>=</span> <span>quest_ref</span><span>.</span><span>get</span><span>()</span><span>if</span> <span>quest</span><span>.</span><span>exists</span><span>:</span><span>logger</span><span>.</span><span>info</span><span>(</span><span>"Quest already exists, updating"</span><span>,</span> <span>quest_id</span><span>=</span><span>quest_id</span><span>)</span><span>try</span><span>:</span><span>quest_obj</span><span>.</span><span>load</span><span>(</span><span>quest</span><span>.</span><span>to_dict</span><span>())</span><span>except</span> <span>QuestLoadError</span> <span>as</span> <span>err</span><span>:</span><span>logger</span><span>.</span><span>error</span><span>(</span><span>"Could not load"</span><span>,</span> <span>err</span><span>=</span><span>err</span><span>)</span><span>raise</span> <span>err</span><span>quest_ref</span><span>.</span><span>set</span><span>(</span><span>quest_obj</span><span>.</span><span>get_save_data</span><span>())</span><span>def</span> <span>create_new_game</span><span>(</span><span>event</span><span>:</span> <span>dict</span><span>,</span> <span>context</span><span>:</span> <span>Context</span><span>):</span> <span>""" Create a new game """</span> <span>logger</span><span>.</span><span>info</span><span>(</span><span>"Got create new game request"</span><span>,</span> <span>payload</span><span>=</span><span>event</span><span>)</span> <span># decode event </span> <span>try</span><span>:</span> <span>new_game_data</span> <span>=</span> <span>NewGameData</span><span>.</span><span>from_event</span><span>(</span><span>event</span><span>)</span> <span>except</span> <span>ValidationError</span> <span>as</span> <span>err</span><span>:</span> <span>logger</span><span>.</span><span>error</span><span>(</span><span>"Validation error"</span><span>,</span> <span>err</span><span>=</span><span>err</span><span>)</span> <span>raise</span> <span>err</span> <span>logger</span><span>.</span><span>info</span><span>(</span><span>"Resolved data"</span><span>,</span> <span>new_game_data</span><span>=</span><span>new_game_data</span><span>)</span> <span># create game if doesn't exist </span> <span>game_id</span> <span>=</span> <span>create_game_id</span><span>(</span><span>new_game_data</span><span>.</span><span>source</span><span>,</span> <span>new_game_data</span><span>.</span><span>userId</span><span>)</span> <span>game_ref</span> <span>=</span> <span>db</span><span>.</span><span>collection</span><span>(</span><span>"game"</span><span>).</span><span>document</span><span>(</span><span>game_id</span><span>)</span> <span>game</span> <span>=</span> <span>game_ref</span><span>.</span><span>get</span><span>()</span> <span>if</span> <span>game</span><span>.</span><span>exists</span><span>:</span> <span>logger</span><span>.</span><span>info</span><span>(</span><span>"Game already exists"</span><span>,</span> <span>game_id</span><span>=</span><span>game_id</span><span>)</span> <span>game_ref</span><span>.</span><span>set</span><span>(</span> <span>{</span> <span>**</span><span>new_game_data</span><span>.</span><span>dict</span><span>(),</span> <span>},</span> <span>merge</span><span>=</span><span>True</span><span>,</span> <span>)</span> <span>else</span><span>:</span> <span>logger</span><span>.</span><span>info</span><span>(</span><span>"Creating new game"</span><span>,</span> <span>game_id</span><span>=</span><span>game_id</span><span>)</span> <span>game_ref</span><span>.</span><span>set</span><span>(</span> <span>{</span> <span>**</span><span>new_game_data</span><span>.</span><span>dict</span><span>(),</span> <span>"joined"</span><span>:</span> <span>firestore</span><span>.</span><span>SERVER_TIMESTAMP</span><span>,</span> <span>}</span> <span>)</span> <span># create starting quest if not exist </span> <span>FirstQuest</span> <span>=</span> <span>get_quest_by_name</span><span>(</span><span>FIRST_QUEST_NAME</span><span>)</span> <span>quest_obj</span> <span>=</span> <span>FirstQuest</span><span>()</span> <span>quest_id</span> <span>=</span> <span>create_quest_id</span><span>(</span><span>game_id</span><span>,</span> <span>FIRST_QUEST_NAME</span><span>)</span> <span>quest_ref</span> <span>=</span> <span>db</span><span>.</span><span>collection</span><span>(</span><span>"quest"</span><span>).</span><span>document</span><span>(</span><span>quest_id</span><span>)</span> <span>quest</span> <span>=</span> <span>quest_ref</span><span>.</span><span>get</span><span>()</span> <span>if</span> <span>quest</span><span>.</span><span>exists</span><span>:</span> <span>logger</span><span>.</span><span>info</span><span>(</span><span>"Quest already exists, updating"</span><span>,</span> <span>quest_id</span><span>=</span><span>quest_id</span><span>)</span> <span>try</span><span>:</span> <span>quest_obj</span><span>.</span><span>load</span><span>(</span><span>quest</span><span>.</span><span>to_dict</span><span>())</span> <span>except</span> <span>QuestLoadError</span> <span>as</span> <span>err</span><span>:</span> <span>logger</span><span>.</span><span>error</span><span>(</span><span>"Could not load"</span><span>,</span> <span>err</span><span>=</span><span>err</span><span>)</span> <span>raise</span> <span>err</span> <span>quest_ref</span><span>.</span><span>set</span><span>(</span><span>quest_obj</span><span>.</span><span>get_save_data</span><span>())</span>def create_new_game(event: dict, context: Context): """ Create a new game """ logger.info("Got create new game request", payload=event) # decode event try: new_game_data = NewGameData.from_event(event) except ValidationError as err: logger.error("Validation error", err=err) raise err logger.info("Resolved data", new_game_data=new_game_data) # create game if doesn't exist game_id = create_game_id(new_game_data.source, new_game_data.userId) game_ref = db.collection("game").document(game_id) game = game_ref.get() if game.exists: logger.info("Game already exists", game_id=game_id) game_ref.set( { **new_game_data.dict(), }, merge=True, ) else: logger.info("Creating new game", game_id=game_id) game_ref.set( { **new_game_data.dict(), "joined": firestore.SERVER_TIMESTAMP, } ) # create starting quest if not exist FirstQuest = get_quest_by_name(FIRST_QUEST_NAME) quest_obj = FirstQuest() quest_id = create_quest_id(game_id, FIRST_QUEST_NAME) quest_ref = db.collection("quest").document(quest_id) quest = quest_ref.get() if quest.exists: logger.info("Quest already exists, updating", quest_id=quest_id) try: quest_obj.load(quest.to_dict()) except QuestLoadError as err: logger.error("Could not load", err=err) raise err quest_ref.set(quest_obj.get_save_data())
Enter fullscreen mode Exit fullscreen mode
Tests
The text fixture for triggering a pub/sub payload as a little more complex, as it needs to contain a json structure. Interestingly, the functions-framework
appears to still use an the test-client with a Post() request, though I don’t know exactly what’s going on under the hood – it’s not well documented and I’m figuring things out from their own unit tests.
<span>@</span><span>pytest</span><span>.</span><span>fixture</span><span>(</span><span>scope</span><span>=</span><span>"package"</span><span>)</span><span>def</span> <span>new_game_post</span><span>():</span><span>""" Test client for newgame"""</span><span>client</span> <span>=</span> <span>create_app</span><span>(</span><span>"create_new_game"</span><span>,</span> <span>FUNCTION_SOURCE</span><span>,</span> <span>"event"</span><span>).</span><span>test_client</span><span>()</span><span>return</span> <span>lambda</span> <span>data</span><span>:</span> <span>client</span><span>.</span><span>post</span><span>(</span><span>"/"</span><span>,</span><span>json</span><span>=</span><span>{</span><span>"context"</span><span>:</span> <span>{</span><span>"eventId"</span><span>:</span> <span>"some-eventId"</span><span>,</span><span>"timestamp"</span><span>:</span> <span>"some-timestamp"</span><span>,</span><span>"eventType"</span><span>:</span> <span>"some-eventType"</span><span>,</span><span>"resource"</span><span>:</span> <span>"some-resource"</span><span>,</span><span>},</span><span>"data"</span><span>:</span> <span>{</span><span>"data"</span><span>:</span> <span>b64encode</span><span>(</span><span>json</span><span>.</span><span>dumps</span><span>(</span><span>data</span><span>).</span><span>encode</span><span>()).</span><span>decode</span><span>()},</span><span>},</span><span>)</span><span>@</span><span>pytest</span><span>.</span><span>fixture</span><span>(</span><span>scope</span><span>=</span><span>"package"</span><span>)</span> <span>def</span> <span>new_game_post</span><span>():</span> <span>""" Test client for newgame"""</span> <span>client</span> <span>=</span> <span>create_app</span><span>(</span><span>"create_new_game"</span><span>,</span> <span>FUNCTION_SOURCE</span><span>,</span> <span>"event"</span><span>).</span><span>test_client</span><span>()</span> <span>return</span> <span>lambda</span> <span>data</span><span>:</span> <span>client</span><span>.</span><span>post</span><span>(</span> <span>"/"</span><span>,</span> <span>json</span><span>=</span><span>{</span> <span>"context"</span><span>:</span> <span>{</span> <span>"eventId"</span><span>:</span> <span>"some-eventId"</span><span>,</span> <span>"timestamp"</span><span>:</span> <span>"some-timestamp"</span><span>,</span> <span>"eventType"</span><span>:</span> <span>"some-eventType"</span><span>,</span> <span>"resource"</span><span>:</span> <span>"some-resource"</span><span>,</span> <span>},</span> <span>"data"</span><span>:</span> <span>{</span><span>"data"</span><span>:</span> <span>b64encode</span><span>(</span><span>json</span><span>.</span><span>dumps</span><span>(</span><span>data</span><span>).</span><span>encode</span><span>()).</span><span>decode</span><span>()},</span> <span>},</span> <span>)</span>@pytest.fixture(scope="package") def new_game_post(): """ Test client for newgame""" client = create_app("create_new_game", FUNCTION_SOURCE, "event").test_client() return lambda data: client.post( "/", json={ "context": { "eventId": "some-eventId", "timestamp": "some-timestamp", "eventType": "some-eventType", "resource": "some-resource", }, "data": {"data": b64encode(json.dumps(data).encode()).decode()}, }, )
Enter fullscreen mode Exit fullscreen mode
The test (I guess an integration test) looks like this:
<span>@</span><span>pytest</span><span>.</span><span>fixture</span><span>def</span> <span>new_game_data</span><span>(</span><span>firestore_client</span><span>):</span><span>uid</span> <span>=</span> <span>"test_user_"</span> <span>+</span> <span>""</span><span>.</span><span>join</span><span>(</span><span>[</span><span>random</span><span>.</span><span>choice</span><span>(</span><span>string</span><span>.</span><span>ascii_letters</span><span>)</span> <span>for</span> <span>_</span> <span>in</span> <span>range</span><span>(</span><span>6</span><span>)]</span><span>)</span><span># create user data </span> <span>yield</span> <span>NewGameData</span><span>(</span><span>source</span><span>=</span><span>SOURCE</span><span>,</span><span>userId</span><span>=</span><span>uid</span><span>,</span><span>userUid</span><span>=</span><span>uid</span><span>,</span><span>forkUrl</span><span>=</span><span>"test_url"</span><span>,</span><span>).</span><span>dict</span><span>()</span><span># cleanup </span> <span>game_id</span> <span>=</span> <span>create_game_id</span><span>(</span><span>SOURCE</span><span>,</span> <span>uid</span><span>)</span><span>quest_id</span> <span>=</span> <span>create_quest_id</span><span>(</span><span>game_id</span><span>,</span> <span>FIRST_QUEST_NAME</span><span>)</span><span>firestore_client</span><span>.</span><span>collection</span><span>(</span><span>"game"</span><span>).</span><span>document</span><span>(</span><span>game_id</span><span>).</span><span>delete</span><span>()</span><span>firestore_client</span><span>.</span><span>collection</span><span>(</span><span>"quest"</span><span>).</span><span>document</span><span>(</span><span>quest_id</span><span>).</span><span>delete</span><span>()</span><span>def</span> <span>test_fail_quest</span><span>(</span><span>firestore_client</span><span>,</span> <span>new_game_post</span><span>,</span> <span>new_game_data</span><span>):</span><span>""" Test situation where quest creation fails """</span><span># check game and quest does not exist </span> <span>game_id</span> <span>=</span> <span>create_game_id</span><span>(</span><span>SOURCE</span><span>,</span> <span>new_game_data</span><span>[</span><span>"userId"</span><span>])</span><span>quest_id</span> <span>=</span> <span>create_quest_id</span><span>(</span><span>game_id</span><span>,</span> <span>FIRST_QUEST_NAME</span><span>)</span><span>firestore_client</span><span>.</span><span>collection</span><span>(</span><span>"quest"</span><span>).</span><span>document</span><span>(</span><span>quest_id</span><span>).</span><span>set</span><span>({</span><span>"_version"</span><span>:</span> <span>"999.9.9"</span><span>})</span><span># create! </span> <span>res</span> <span>=</span> <span>new_game_post</span><span>(</span><span>new_game_data</span><span>)</span><span>assert</span> <span>res</span><span>.</span><span>status_code</span> <span>==</span> <span>500</span><span>def</span> <span>test_game_creation</span><span>(</span><span>firestore_client</span><span>,</span> <span>new_game_post</span><span>,</span> <span>new_game_data</span><span>):</span><span>""" Test successful game creation flow """</span><span># check game and quest does not exist </span> <span>game_id</span> <span>=</span> <span>create_game_id</span><span>(</span><span>SOURCE</span><span>,</span> <span>new_game_data</span><span>[</span><span>"userId"</span><span>])</span><span>quest_id</span> <span>=</span> <span>create_quest_id</span><span>(</span><span>game_id</span><span>,</span> <span>FIRST_QUEST_NAME</span><span>)</span><span>game</span> <span>=</span> <span>firestore_client</span><span>.</span><span>collection</span><span>(</span><span>"game"</span><span>).</span><span>document</span><span>(</span><span>game_id</span><span>).</span><span>get</span><span>()</span><span>assert</span> <span>not</span> <span>game</span><span>.</span><span>exists</span><span>quest</span> <span>=</span> <span>firestore_client</span><span>.</span><span>collection</span><span>(</span><span>"quest"</span><span>).</span><span>document</span><span>(</span><span>quest_id</span><span>).</span><span>get</span><span>()</span><span>assert</span> <span>not</span> <span>quest</span><span>.</span><span>exists</span><span># create! </span> <span>res</span> <span>=</span> <span>new_game_post</span><span>(</span><span>new_game_data</span><span>)</span><span>assert</span> <span>res</span><span>.</span><span>status_code</span> <span>==</span> <span>200</span><span># check if game actually created, and that it contains data </span> <span>game</span> <span>=</span> <span>firestore_client</span><span>.</span><span>collection</span><span>(</span><span>"game"</span><span>).</span><span>document</span><span>(</span><span>game_id</span><span>).</span><span>get</span><span>()</span><span>assert</span> <span>game</span><span>.</span><span>exists</span><span>game_dict</span> <span>=</span> <span>game</span><span>.</span><span>to_dict</span><span>()</span><span>assert</span> <span>game_dict</span><span>.</span><span>items</span><span>()</span> <span>>=</span> <span>new_game_data</span><span>.</span><span>items</span><span>()</span><span># check if quest was created and that it contains data </span> <span>quest</span> <span>=</span> <span>firestore_client</span><span>.</span><span>collection</span><span>(</span><span>"quest"</span><span>).</span><span>document</span><span>(</span><span>quest_id</span><span>).</span><span>get</span><span>()</span><span>assert</span> <span>quest</span><span>.</span><span>exists</span><span># try create again, sohuld still work, but be idempotent </span> <span>res</span> <span>=</span> <span>new_game_post</span><span>(</span><span>new_game_data</span><span>)</span><span>assert</span> <span>res</span><span>.</span><span>status_code</span> <span>==</span> <span>200</span><span>@</span><span>pytest</span><span>.</span><span>fixture</span> <span>def</span> <span>new_game_data</span><span>(</span><span>firestore_client</span><span>):</span> <span>uid</span> <span>=</span> <span>"test_user_"</span> <span>+</span> <span>""</span><span>.</span><span>join</span><span>(</span> <span>[</span><span>random</span><span>.</span><span>choice</span><span>(</span><span>string</span><span>.</span><span>ascii_letters</span><span>)</span> <span>for</span> <span>_</span> <span>in</span> <span>range</span><span>(</span><span>6</span><span>)]</span> <span>)</span> <span># create user data </span> <span>yield</span> <span>NewGameData</span><span>(</span> <span>source</span><span>=</span><span>SOURCE</span><span>,</span> <span>userId</span><span>=</span><span>uid</span><span>,</span> <span>userUid</span><span>=</span><span>uid</span><span>,</span> <span>forkUrl</span><span>=</span><span>"test_url"</span><span>,</span> <span>).</span><span>dict</span><span>()</span> <span># cleanup </span> <span>game_id</span> <span>=</span> <span>create_game_id</span><span>(</span><span>SOURCE</span><span>,</span> <span>uid</span><span>)</span> <span>quest_id</span> <span>=</span> <span>create_quest_id</span><span>(</span><span>game_id</span><span>,</span> <span>FIRST_QUEST_NAME</span><span>)</span> <span>firestore_client</span><span>.</span><span>collection</span><span>(</span><span>"game"</span><span>).</span><span>document</span><span>(</span><span>game_id</span><span>).</span><span>delete</span><span>()</span> <span>firestore_client</span><span>.</span><span>collection</span><span>(</span><span>"quest"</span><span>).</span><span>document</span><span>(</span><span>quest_id</span><span>).</span><span>delete</span><span>()</span> <span>def</span> <span>test_fail_quest</span><span>(</span><span>firestore_client</span><span>,</span> <span>new_game_post</span><span>,</span> <span>new_game_data</span><span>):</span> <span>""" Test situation where quest creation fails """</span> <span># check game and quest does not exist </span> <span>game_id</span> <span>=</span> <span>create_game_id</span><span>(</span><span>SOURCE</span><span>,</span> <span>new_game_data</span><span>[</span><span>"userId"</span><span>])</span> <span>quest_id</span> <span>=</span> <span>create_quest_id</span><span>(</span><span>game_id</span><span>,</span> <span>FIRST_QUEST_NAME</span><span>)</span> <span>firestore_client</span><span>.</span><span>collection</span><span>(</span><span>"quest"</span><span>).</span><span>document</span><span>(</span><span>quest_id</span><span>).</span><span>set</span><span>({</span><span>"_version"</span><span>:</span> <span>"999.9.9"</span><span>})</span> <span># create! </span> <span>res</span> <span>=</span> <span>new_game_post</span><span>(</span><span>new_game_data</span><span>)</span> <span>assert</span> <span>res</span><span>.</span><span>status_code</span> <span>==</span> <span>500</span> <span>def</span> <span>test_game_creation</span><span>(</span><span>firestore_client</span><span>,</span> <span>new_game_post</span><span>,</span> <span>new_game_data</span><span>):</span> <span>""" Test successful game creation flow """</span> <span># check game and quest does not exist </span> <span>game_id</span> <span>=</span> <span>create_game_id</span><span>(</span><span>SOURCE</span><span>,</span> <span>new_game_data</span><span>[</span><span>"userId"</span><span>])</span> <span>quest_id</span> <span>=</span> <span>create_quest_id</span><span>(</span><span>game_id</span><span>,</span> <span>FIRST_QUEST_NAME</span><span>)</span> <span>game</span> <span>=</span> <span>firestore_client</span><span>.</span><span>collection</span><span>(</span><span>"game"</span><span>).</span><span>document</span><span>(</span><span>game_id</span><span>).</span><span>get</span><span>()</span> <span>assert</span> <span>not</span> <span>game</span><span>.</span><span>exists</span> <span>quest</span> <span>=</span> <span>firestore_client</span><span>.</span><span>collection</span><span>(</span><span>"quest"</span><span>).</span><span>document</span><span>(</span><span>quest_id</span><span>).</span><span>get</span><span>()</span> <span>assert</span> <span>not</span> <span>quest</span><span>.</span><span>exists</span> <span># create! </span> <span>res</span> <span>=</span> <span>new_game_post</span><span>(</span><span>new_game_data</span><span>)</span> <span>assert</span> <span>res</span><span>.</span><span>status_code</span> <span>==</span> <span>200</span> <span># check if game actually created, and that it contains data </span> <span>game</span> <span>=</span> <span>firestore_client</span><span>.</span><span>collection</span><span>(</span><span>"game"</span><span>).</span><span>document</span><span>(</span><span>game_id</span><span>).</span><span>get</span><span>()</span> <span>assert</span> <span>game</span><span>.</span><span>exists</span> <span>game_dict</span> <span>=</span> <span>game</span><span>.</span><span>to_dict</span><span>()</span> <span>assert</span> <span>game_dict</span><span>.</span><span>items</span><span>()</span> <span>>=</span> <span>new_game_data</span><span>.</span><span>items</span><span>()</span> <span># check if quest was created and that it contains data </span> <span>quest</span> <span>=</span> <span>firestore_client</span><span>.</span><span>collection</span><span>(</span><span>"quest"</span><span>).</span><span>document</span><span>(</span><span>quest_id</span><span>).</span><span>get</span><span>()</span> <span>assert</span> <span>quest</span><span>.</span><span>exists</span> <span># try create again, sohuld still work, but be idempotent </span> <span>res</span> <span>=</span> <span>new_game_post</span><span>(</span><span>new_game_data</span><span>)</span> <span>assert</span> <span>res</span><span>.</span><span>status_code</span> <span>==</span> <span>200</span>@pytest.fixture def new_game_data(firestore_client): uid = "test_user_" + "".join( [random.choice(string.ascii_letters) for _ in range(6)] ) # create user data yield NewGameData( source=SOURCE, userId=uid, userUid=uid, forkUrl="test_url", ).dict() # cleanup game_id = create_game_id(SOURCE, uid) quest_id = create_quest_id(game_id, FIRST_QUEST_NAME) firestore_client.collection("game").document(game_id).delete() firestore_client.collection("quest").document(quest_id).delete() def test_fail_quest(firestore_client, new_game_post, new_game_data): """ Test situation where quest creation fails """ # check game and quest does not exist game_id = create_game_id(SOURCE, new_game_data["userId"]) quest_id = create_quest_id(game_id, FIRST_QUEST_NAME) firestore_client.collection("quest").document(quest_id).set({"_version": "999.9.9"}) # create! res = new_game_post(new_game_data) assert res.status_code == 500 def test_game_creation(firestore_client, new_game_post, new_game_data): """ Test successful game creation flow """ # check game and quest does not exist game_id = create_game_id(SOURCE, new_game_data["userId"]) quest_id = create_quest_id(game_id, FIRST_QUEST_NAME) game = firestore_client.collection("game").document(game_id).get() assert not game.exists quest = firestore_client.collection("quest").document(quest_id).get() assert not quest.exists # create! res = new_game_post(new_game_data) assert res.status_code == 200 # check if game actually created, and that it contains data game = firestore_client.collection("game").document(game_id).get() assert game.exists game_dict = game.to_dict() assert game_dict.items() >= new_game_data.items() # check if quest was created and that it contains data quest = firestore_client.collection("quest").document(quest_id).get() assert quest.exists # try create again, sohuld still work, but be idempotent res = new_game_post(new_game_data) assert res.status_code == 200
Enter fullscreen mode Exit fullscreen mode
It’s a little unclear, I’m beginning to feel that I need to significantly modularize and refactor this codebase at this point, as we now have game logic strewn across endpoints, a lot of duplicate boilercode. I have some ways in mind of how to deal with it, and will leave it for later.
LGTM (40 Part Series)
1 LGTM Devlog 0: Teaching Git through playing a game
2 LGTM Devlog 1: New Logo, and jumping the gun by buying a domain
… 36 more parts…
3 LGTM Devlog 2: Design Ideas for the game I
4 LGTM Devlog 3: Design Ideas for the game 2
5 LGTM Devlog 4: Technical Decisions – GitHub API and Python
6 LGTM Devlog 5: Technical Decisions – Serverless Architecture
7 LGTM Devlog 6: Sprint 1 plans
8 LGTM Devlog 7: Creating a Firebase project
9 LGTM Devlog 8: Starting a git repo for an open source project from scratch
10 LGTM Devlog 9: Python Google Cloud Functions with Unit Tests and Linting
11 LGTM Devlog 10: Capturing the GitHub webhook for fork requests
12 LGTM Devlog 11: Writing the Serverless Function for receiving GitHub webhooks with Pydantic validation
13 LGTM Devlog 12: CI/CD with GitHub Actions to run Unit Tests and deploy Firebase Functions
14 LGTM Devlog 13: GitHub Branch Protection and Security
15 LGTM Devlog 14: Sprint 1 Retrospective
16 LGTM Devlog 15: Sprint 2 plans
17 LGTM Devlog 16: A serverless data base access rule conundrum
18 LGTM Devlog 17: Website and GitHub OAuth
19 LGTM Devlog 18: Python Serverless functions using GitHub API to validate users
20 LGTM Devlog 19: Game data/quest storage
21 LGTM Devlog 20: Python Abstract Base Class-based data/quest storage
22 LGTM Devlog 21: Deploying Pub/Sub-triggered Python Google Cloud Functions
23 LGTM Devlog 22: Modularization
24 LGTM Devlog 23: Sprint 2 Retrospective
25 LGTM Devlog 24: Sprint 3 plans
26 LGTM Devlog 25: Some cleanup
27 LGTM Devlog 26: Python Graphlib DAGs for Quest Stages
28 LGTM Devlog 27: Branching quests
29 LGTM Devlog 28: Game event loop using Google Cloud Scheduler and PubSub
30 LGTM Devlog 29: ORM for Firestore and __init__subclass__ dunders and metaclasses
31 LGTM Devlog 30: Sprint 3 Retrospective
32 LGTM Devlog 31: Sprint 4 Plan
33 LGTM Devlog 32: Secrets Management to avoid storing API keys in services
34 LGTM Devlog 33: Using PyGithub to post GitHub issues and comments
35 LGTM Devlog 34: Characters Posting on GitHub Issues
36 LGTM Devlog 35: Responding to the player’s answers on GitHub Issues Comments
37 LGTM Devlog 36: Character profiles! On GitHub
38 LGTM Devlog 37: Sprint 4 Retrospective
39 LGTM Devlog 38: Sprint 5 Plan
40 LGTM Devlog 39: Planning the story
原文链接:LGTM Devlog 21: Deploying Pub/Sub-triggered Python Google Cloud Functions
暂无评论内容