patchground 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- patchground/__init__.py +10 -0
- patchground/__main__.py +3 -0
- patchground/authoring/__init__.py +5 -0
- patchground/authoring/create.py +179 -0
- patchground/authoring/templates/python-debug/TICKET.md.tmpl +11 -0
- patchground/authoring/templates/python-debug/challenge.yaml.tmpl +21 -0
- patchground/authoring/templates/python-debug/checks/hidden/tests/test_starter_hidden.py.tmpl +13 -0
- patchground/authoring/templates/python-debug/checks/visible/tests/test_starter.py.tmpl +9 -0
- patchground/authoring/templates/python-debug/environment/Dockerfile.tmpl +3 -0
- patchground/authoring/templates/python-debug/solution/reference.patch.tmpl +8 -0
- patchground/authoring/templates/python-debug/workspace/pyproject.toml.tmpl +8 -0
- patchground/authoring/templates/python-debug/workspace/src/starter.py.tmpl +3 -0
- patchground/authoring/templates/python-feature/TICKET.md.tmpl +13 -0
- patchground/authoring/templates/python-feature/challenge.yaml.tmpl +22 -0
- patchground/authoring/templates/python-feature/checks/hidden/tests/test_starter_hidden.py.tmpl +13 -0
- patchground/authoring/templates/python-feature/checks/visible/tests/test_starter.py.tmpl +9 -0
- patchground/authoring/templates/python-feature/environment/Dockerfile.tmpl +3 -0
- patchground/authoring/templates/python-feature/solution/reference.patch.tmpl +9 -0
- patchground/authoring/templates/python-feature/workspace/pyproject.toml.tmpl +8 -0
- patchground/authoring/templates/python-feature/workspace/src/starter.py.tmpl +8 -0
- patchground/authoring/templates/typescript-debug/TICKET.md.tmpl +11 -0
- patchground/authoring/templates/typescript-debug/challenge.yaml.tmpl +21 -0
- patchground/authoring/templates/typescript-debug/checks/hidden/tests/starter-hidden.test.ts.tmpl +14 -0
- patchground/authoring/templates/typescript-debug/checks/visible/tests/starter.test.ts.tmpl +10 -0
- patchground/authoring/templates/typescript-debug/solution/reference.patch.tmpl +9 -0
- patchground/authoring/templates/typescript-debug/workspace/package.json.tmpl +6 -0
- patchground/authoring/templates/typescript-debug/workspace/src/starter.ts.tmpl +4 -0
- patchground/authoring/templates/typescript-feature/TICKET.md.tmpl +13 -0
- patchground/authoring/templates/typescript-feature/challenge.yaml.tmpl +22 -0
- patchground/authoring/templates/typescript-feature/checks/hidden/tests/starter-hidden.test.ts.tmpl +14 -0
- patchground/authoring/templates/typescript-feature/checks/visible/tests/starter.test.ts.tmpl +10 -0
- patchground/authoring/templates/typescript-feature/solution/reference.patch.tmpl +10 -0
- patchground/authoring/templates/typescript-feature/workspace/package.json.tmpl +6 -0
- patchground/authoring/templates/typescript-feature/workspace/src/starter.ts.tmpl +9 -0
- patchground/automation/__init__.py +5 -0
- patchground/automation/__main__.py +131 -0
- patchground/automation/workflow.py +214 -0
- patchground/catalog/README.md +10 -0
- patchground/catalog/cache-expiry/TICKET.md +49 -0
- patchground/catalog/cache-expiry/challenge.yaml +32 -0
- patchground/catalog/cache-expiry/checks/hidden/tests/test_cache_hidden.py +164 -0
- patchground/catalog/cache-expiry/checks/visible/tests/test_cache.py +56 -0
- patchground/catalog/cache-expiry/debrief/questions.md +4 -0
- patchground/catalog/cache-expiry/environment/Dockerfile +3 -0
- patchground/catalog/cache-expiry/solution/reference.patch +41 -0
- patchground/catalog/cache-expiry/workspace/pyproject.toml +8 -0
- patchground/catalog/cache-expiry/workspace/src/profilecache/__init__.py +6 -0
- patchground/catalog/cache-expiry/workspace/src/profilecache/cache.py +67 -0
- patchground/catalog/cache-expiry/workspace/src/profilecache/client.py +38 -0
- patchground/catalog/csv-import-row-errors/TICKET.md +97 -0
- patchground/catalog/csv-import-row-errors/challenge.yaml +33 -0
- patchground/catalog/csv-import-row-errors/checks/hidden/tests/test_import_hidden.py +359 -0
- patchground/catalog/csv-import-row-errors/checks/visible/tests/test_import.py +34 -0
- patchground/catalog/csv-import-row-errors/debrief/questions.md +5 -0
- patchground/catalog/csv-import-row-errors/environment/Dockerfile +3 -0
- patchground/catalog/csv-import-row-errors/solution/reference.patch +74 -0
- patchground/catalog/csv-import-row-errors/workspace/pyproject.toml +8 -0
- patchground/catalog/csv-import-row-errors/workspace/src/stockimport/__init__.py +4 -0
- patchground/catalog/csv-import-row-errors/workspace/src/stockimport/importer.py +69 -0
- patchground/catalog/csv-import-row-errors/workspace/src/stockimport/models.py +32 -0
- patchground/catalog/duplicate-charges-on-retry/TICKET.md +28 -0
- patchground/catalog/duplicate-charges-on-retry/challenge.yaml +30 -0
- patchground/catalog/duplicate-charges-on-retry/checks/hidden/tests/test_payment_regressions.py +87 -0
- patchground/catalog/duplicate-charges-on-retry/checks/visible/tests/test_checkout.py +34 -0
- patchground/catalog/duplicate-charges-on-retry/debrief/questions.md +6 -0
- patchground/catalog/duplicate-charges-on-retry/environment/Dockerfile +3 -0
- patchground/catalog/duplicate-charges-on-retry/solution/reference.patch +11 -0
- patchground/catalog/duplicate-charges-on-retry/workspace/pyproject.toml +8 -0
- patchground/catalog/duplicate-charges-on-retry/workspace/src/payments/__init__.py +1 -0
- patchground/catalog/duplicate-charges-on-retry/workspace/src/payments/gateway.py +38 -0
- patchground/catalog/duplicate-charges-on-retry/workspace/src/payments/models.py +19 -0
- patchground/catalog/duplicate-charges-on-retry/workspace/src/payments/service.py +23 -0
- patchground/catalog/event-bus-subscriptions/TICKET.md +108 -0
- patchground/catalog/event-bus-subscriptions/challenge.yaml +33 -0
- patchground/catalog/event-bus-subscriptions/checks/hidden/tests/event-bus-contract.test.ts +566 -0
- patchground/catalog/event-bus-subscriptions/checks/visible/tests/event-bus.test.ts +70 -0
- patchground/catalog/event-bus-subscriptions/debrief/questions.md +5 -0
- patchground/catalog/event-bus-subscriptions/environment/Dockerfile +7 -0
- patchground/catalog/event-bus-subscriptions/environment/package-lock.json +1054 -0
- patchground/catalog/event-bus-subscriptions/environment/package.json +13 -0
- patchground/catalog/event-bus-subscriptions/environment/vitest-runner.mjs +124 -0
- patchground/catalog/event-bus-subscriptions/solution/reference.patch +82 -0
- patchground/catalog/event-bus-subscriptions/workspace/package.json +6 -0
- patchground/catalog/event-bus-subscriptions/workspace/src/bus.ts +32 -0
- patchground/catalog/order-notifications-boundary/TICKET.md +51 -0
- patchground/catalog/order-notifications-boundary/challenge.yaml +32 -0
- patchground/catalog/order-notifications-boundary/checks/hidden/tests/test_boundary_hidden.py +87 -0
- patchground/catalog/order-notifications-boundary/checks/visible/tests/test_confirmation.py +41 -0
- patchground/catalog/order-notifications-boundary/debrief/questions.md +5 -0
- patchground/catalog/order-notifications-boundary/environment/Dockerfile +3 -0
- patchground/catalog/order-notifications-boundary/solution/reference.patch +77 -0
- patchground/catalog/order-notifications-boundary/workspace/pyproject.toml +8 -0
- patchground/catalog/order-notifications-boundary/workspace/src/orders/__init__.py +6 -0
- patchground/catalog/order-notifications-boundary/workspace/src/orders/app.py +9 -0
- patchground/catalog/order-notifications-boundary/workspace/src/orders/models.py +25 -0
- patchground/catalog/order-notifications-boundary/workspace/src/orders/service.py +31 -0
- patchground/catalog/order-notifications-boundary/workspace/src/orders/smtp_gateway.py +19 -0
- patchground/catalog/order-notifications-boundary/workspace/src/orders/sqlite_store.py +44 -0
- patchground/catalog/pagination-last-page/TICKET.md +44 -0
- patchground/catalog/pagination-last-page/challenge.yaml +31 -0
- patchground/catalog/pagination-last-page/checks/hidden/tests/test_pagination_hidden.py +32 -0
- patchground/catalog/pagination-last-page/checks/visible/tests/test_pagination.py +37 -0
- patchground/catalog/pagination-last-page/debrief/questions.md +9 -0
- patchground/catalog/pagination-last-page/environment/Dockerfile +3 -0
- patchground/catalog/pagination-last-page/solution/reference.patch +22 -0
- patchground/catalog/pagination-last-page/workspace/pyproject.toml +8 -0
- patchground/catalog/pagination-last-page/workspace/src/catalog/__init__.py +1 -0
- patchground/catalog/pagination-last-page/workspace/src/catalog/pagination.py +52 -0
- patchground/catalog/release-version-ordering/TICKET.md +72 -0
- patchground/catalog/release-version-ordering/challenge.yaml +31 -0
- patchground/catalog/release-version-ordering/checks/hidden/tests/releasekit_contract.test.ts +92 -0
- patchground/catalog/release-version-ordering/checks/visible/tests/releasekit.test.ts +31 -0
- patchground/catalog/release-version-ordering/debrief/questions.md +9 -0
- patchground/catalog/release-version-ordering/environment/Dockerfile +7 -0
- patchground/catalog/release-version-ordering/environment/package-lock.json +1054 -0
- patchground/catalog/release-version-ordering/environment/package.json +13 -0
- patchground/catalog/release-version-ordering/environment/vitest-runner.mjs +124 -0
- patchground/catalog/release-version-ordering/solution/reference.patch +36 -0
- patchground/catalog/release-version-ordering/workspace/README.md +7 -0
- patchground/catalog/release-version-ordering/workspace/src/releasekit/index.ts +2 -0
- patchground/catalog/release-version-ordering/workspace/src/releasekit/ordering.ts +41 -0
- patchground/catalog/release-version-ordering/workspace/src/releasekit/parse.ts +24 -0
- patchground/cli/__init__.py +1 -0
- patchground/cli/app.py +59 -0
- patchground/cli/authoring.py +41 -0
- patchground/cli/consent.py +77 -0
- patchground/cli/discovery.py +189 -0
- patchground/cli/doctor.py +249 -0
- patchground/cli/packs.py +142 -0
- patchground/cli/practice.py +141 -0
- patchground/cli/render.py +58 -0
- patchground/cli/reports.py +53 -0
- patchground/cli/sessions.py +195 -0
- patchground/cli/validate.py +117 -0
- patchground/packs/__init__.py +23 -0
- patchground/packs/builtin.py +53 -0
- patchground/packs/discovery.py +285 -0
- patchground/packs/execution.py +420 -0
- patchground/packs/models.py +98 -0
- patchground/packs/registry.py +318 -0
- patchground/packs/source.py +230 -0
- patchground/packs/store.py +420 -0
- patchground/py.typed +0 -0
- patchground/report/__init__.py +6 -0
- patchground/report/analysis.py +188 -0
- patchground/report/models.py +59 -0
- patchground/report/regression.py +597 -0
- patchground/report/render.py +92 -0
- patchground/report/workflow.py +84 -0
- patchground/runner_assets/Dockerfile +7 -0
- patchground/runner_assets/__init__.py +16 -0
- patchground/runner_assets/package-lock.json +1054 -0
- patchground/runner_assets/package.json +13 -0
- patchground/runner_assets/vitest-runner.mjs +124 -0
- patchground/safeio/__init__.py +115 -0
- patchground/sandbox/__init__.py +39 -0
- patchground/sandbox/_engine.py +23 -0
- patchground/sandbox/build.py +202 -0
- patchground/sandbox/errors.py +23 -0
- patchground/sandbox/junit.py +76 -0
- patchground/sandbox/probe.py +118 -0
- patchground/sandbox/run.py +535 -0
- patchground/sessions/__init__.py +44 -0
- patchground/sessions/diagnostics.py +145 -0
- patchground/sessions/files.py +113 -0
- patchground/sessions/git.py +70 -0
- patchground/sessions/history.py +386 -0
- patchground/sessions/history_ignore.py +279 -0
- patchground/sessions/models.py +126 -0
- patchground/sessions/practice.py +202 -0
- patchground/sessions/reset.py +121 -0
- patchground/sessions/store.py +203 -0
- patchground/sessions/submission.py +344 -0
- patchground/sessions/workflow.py +297 -0
- patchground/spec/__init__.py +35 -0
- patchground/spec/_schema.py +53 -0
- patchground/spec/loader.py +713 -0
- patchground/spec/models.py +394 -0
- patchground/spec/schema.py +50 -0
- patchground/verify/__init__.py +29 -0
- patchground/verify/errors.py +10 -0
- patchground/verify/pytest_adapter.py +95 -0
- patchground/verify/record.py +94 -0
- patchground/verify/reference.py +158 -0
- patchground/verify/result.py +199 -0
- patchground/verify/runners.py +47 -0
- patchground/verify/tampering.py +106 -0
- patchground/verify/validate.py +686 -0
- patchground/verify/vitest_adapter.py +252 -0
- patchground-0.1.0.dist-info/METADATA +258 -0
- patchground-0.1.0.dist-info/RECORD +194 -0
- patchground-0.1.0.dist-info/WHEEL +4 -0
- patchground-0.1.0.dist-info/entry_points.txt +3 -0
- patchground-0.1.0.dist-info/licenses/LICENSE +202 -0
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import { mkdtemp, readFile, writeFile, rename, rm, lstat, realpath } from 'node:fs/promises';
|
|
3
|
+
import { tmpdir } from 'node:os';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { fileURLToPath } from 'node:url';
|
|
6
|
+
|
|
7
|
+
const root = '/workspace';
|
|
8
|
+
const final = '/results/junit.xml';
|
|
9
|
+
const self = fileURLToPath(import.meta.url);
|
|
10
|
+
|
|
11
|
+
async function child(destination, files) {
|
|
12
|
+
const { startVitest } = await import('vitest/node');
|
|
13
|
+
let completed;
|
|
14
|
+
const reporter = {
|
|
15
|
+
onTestModuleCollected(module) {
|
|
16
|
+
for (const test of module.children.allTests()) {
|
|
17
|
+
const retry = test.options.retry;
|
|
18
|
+
const count = typeof retry === 'number' ? retry : retry?.count ?? 0;
|
|
19
|
+
if (count > 0 || (test.options.repeats ?? 0) > 0) {
|
|
20
|
+
throw Error('Retries and repeats are unsupported');
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
},
|
|
24
|
+
onTestRunEnd(modules, errors, reason) {
|
|
25
|
+
const cases = [];
|
|
26
|
+
let trustworthy = errors.length === 0 && reason !== 'interrupted';
|
|
27
|
+
for (const module of modules) {
|
|
28
|
+
if (module.errors().length) trustworthy = false;
|
|
29
|
+
for (const suite of module.children.allSuites()) {
|
|
30
|
+
if (suite.errors().length) trustworthy = false;
|
|
31
|
+
}
|
|
32
|
+
for (const test of module.children.allTests()) {
|
|
33
|
+
const state = test.result().state;
|
|
34
|
+
if (!['passed', 'failed', 'skipped'].includes(state)) trustworthy = false;
|
|
35
|
+
cases.push({ file: module.relativeModuleId, name: test.fullName, state });
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
completed = { trustworthy, cases };
|
|
39
|
+
},
|
|
40
|
+
};
|
|
41
|
+
const ctx = await startVitest([], {
|
|
42
|
+
root, config: false, watch: false, run: true,
|
|
43
|
+
include: files.length ? files : ['**/*.test.ts'], exclude: ['**/node_modules/**', '**/.git/**'],
|
|
44
|
+
reporters: [reporter], environment: 'node', pool: 'forks', maxWorkers: 1,
|
|
45
|
+
fileParallelism: false, isolate: true, retry: 0, allowOnly: false, maxConcurrency: 1,
|
|
46
|
+
passWithNoTests: false, testTimeout: 5000, hookTimeout: 5000,
|
|
47
|
+
sequence: { shuffle: false },
|
|
48
|
+
}, {
|
|
49
|
+
configFile: false, envDir: false, cacheDir: '/tmp/patchground-vite',
|
|
50
|
+
resolve: { alias: { vitest: '/opt/patchground/node_modules/vitest/dist/index.js' } },
|
|
51
|
+
});
|
|
52
|
+
await ctx.close();
|
|
53
|
+
if (!completed || ctx.state.getUnhandledErrors().length) return;
|
|
54
|
+
await writeFile(destination, JSON.stringify(completed), { flag: 'wx' });
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function escape(value) {
|
|
58
|
+
return value.replaceAll('&', '&').replaceAll('<', '<').replaceAll('>', '>')
|
|
59
|
+
.replaceAll('"', '"').replaceAll("'", ''').replaceAll("\n", " ")
|
|
60
|
+
.replaceAll("\r", " ").replaceAll("\t", "	");
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
async function parent() {
|
|
64
|
+
await rm(final, { force: true });
|
|
65
|
+
const temporary = await mkdtemp(path.join(tmpdir(), 'patchground-vitest-'));
|
|
66
|
+
try {
|
|
67
|
+
const receipt = path.join(temporary, 'receipt.json');
|
|
68
|
+
const args = process.argv.slice(2);
|
|
69
|
+
const files = args.length ? args.slice(1) : [];
|
|
70
|
+
if (args.length && (args[0] !== '--files' || !files.length)) return;
|
|
71
|
+
for (const file of files) {
|
|
72
|
+
const parts = file.split('/');
|
|
73
|
+
if (!file.endsWith('.test.ts') || parts.some(part =>
|
|
74
|
+
!/^[\p{L}\p{N}_. -]+$/u.test(part) || ['.', '..', '.git', 'node_modules'].includes(part))) return;
|
|
75
|
+
}
|
|
76
|
+
if (new Set(files).size !== files.length) return;
|
|
77
|
+
for (const file of files) {
|
|
78
|
+
const full = path.join(root, file);
|
|
79
|
+
if (!(await lstat(full)).isFile() || !(await realpath(full)).startsWith(root + '/')) return;
|
|
80
|
+
}
|
|
81
|
+
const code = await new Promise((resolve, reject) => {
|
|
82
|
+
const process = spawn(globalThis.process.execPath, [self, '--child', receipt, ...files], {
|
|
83
|
+
stdio: 'ignore', env: { ...globalThis.process.env, CI: 'true' },
|
|
84
|
+
});
|
|
85
|
+
process.once('error', reject);
|
|
86
|
+
process.once('exit', (code, signal) => resolve(signal ? null : code));
|
|
87
|
+
});
|
|
88
|
+
const result = JSON.parse(await readFile(receipt, 'utf8'));
|
|
89
|
+
if (!result.trustworthy || !result.cases.length) return;
|
|
90
|
+
const failed = result.cases.some(test => test.state === 'failed');
|
|
91
|
+
if (code !== (failed ? 1 : 0)) return;
|
|
92
|
+
const seen = new Set();
|
|
93
|
+
result.cases.sort((a, b) => {
|
|
94
|
+
const left = `${a.file}::${a.name}`;
|
|
95
|
+
const right = `${b.file}::${b.name}`;
|
|
96
|
+
return left < right ? -1 : left > right ? 1 : 0;
|
|
97
|
+
});
|
|
98
|
+
const cases = result.cases.map(test => {
|
|
99
|
+
if (!test.file || path.posix.isAbsolute(test.file) || test.file.split('/').includes('..')
|
|
100
|
+
|| test.file.includes('\\') || !test.file.endsWith('.test.ts') || !test.name
|
|
101
|
+
|| !test.file.isWellFormed() || !test.name.isWellFormed()
|
|
102
|
+
|| /[\u0000-\u0008\u000b\u000c\u000e-\u001f\ufffe\uffff]/u.test(test.file + test.name)) throw Error('unsafe identity');
|
|
103
|
+
const id = `${test.file}::${test.name}`;
|
|
104
|
+
if (seen.has(id)) throw Error('duplicate identity');
|
|
105
|
+
seen.add(id);
|
|
106
|
+
const status = test.state === 'failed' ? '<failure message="test failed"/>'
|
|
107
|
+
: test.state === 'skipped' ? '<skipped/>' : '';
|
|
108
|
+
return `<testcase classname="${escape(test.file)}" name="${escape(test.name)}">${status}</testcase>`;
|
|
109
|
+
});
|
|
110
|
+
const staging = '/results/.patchground-junit.xml';
|
|
111
|
+
await writeFile(staging, `<?xml version="1.0" encoding="UTF-8"?><testsuites><testsuite name="vitest">${cases.join('')}</testsuite></testsuites>`);
|
|
112
|
+
await rename(staging, final);
|
|
113
|
+
process.exitCode = failed ? 1 : 0;
|
|
114
|
+
} finally {
|
|
115
|
+
await rm(temporary, { recursive: true, force: true });
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
try {
|
|
120
|
+
if (process.argv[2] === '--child') await child(process.argv[3], process.argv.slice(4));
|
|
121
|
+
else { process.exitCode = 1; await parent(); }
|
|
122
|
+
} catch {
|
|
123
|
+
process.exitCode = 1;
|
|
124
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
diff --git a/src/bus.ts b/src/bus.ts
|
|
2
|
+
index 60d54f7..86d7dec 100644
|
|
3
|
+
--- a/src/bus.ts
|
|
4
|
+
+++ b/src/bus.ts
|
|
5
|
+
@@ -3,30 +3,65 @@
|
|
6
|
+
export type Handler = (payload: unknown) => void;
|
|
7
|
+
export type Unsubscribe = () => void;
|
|
8
|
+
|
|
9
|
+
+interface Subscription {
|
|
10
|
+
+ readonly handler: Handler;
|
|
11
|
+
+ readonly once: boolean;
|
|
12
|
+
+ consumed: boolean;
|
|
13
|
+
+}
|
|
14
|
+
+
|
|
15
|
+
export class EventBus {
|
|
16
|
+
- private readonly handlers = new Map<string, Handler[]>();
|
|
17
|
+
+ private readonly subscriptions = new Map<string, Subscription[]>();
|
|
18
|
+
|
|
19
|
+
/** Register `handler` for exactly `topic`. Handlers run in registration order. */
|
|
20
|
+
on(topic: string, handler: Handler): Unsubscribe {
|
|
21
|
+
- const list = this.handlers.get(topic) ?? [];
|
|
22
|
+
- list.push(handler);
|
|
23
|
+
- this.handlers.set(topic, list);
|
|
24
|
+
- // Not implemented yet: there is no way to take a subscription back.
|
|
25
|
+
- return () => {};
|
|
26
|
+
+ return this.subscribe(topic, handler, false);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
- /** Not implemented yet. */
|
|
30
|
+
- once(_topic: string, _handler: Handler): Unsubscribe {
|
|
31
|
+
- throw new Error("EventBus.once is not implemented");
|
|
32
|
+
+ /** Like `on`, but the handler is consumed immediately before its first call. */
|
|
33
|
+
+ once(topic: string, handler: Handler): Unsubscribe {
|
|
34
|
+
+ return this.subscribe(topic, handler, true);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
- /** Call every handler registered for `topic` and return how many were called. */
|
|
38
|
+
+ /**
|
|
39
|
+
+ * Call the subscriptions that exist when the emit starts and return how many handlers were called.
|
|
40
|
+
+ * Throws an AggregateError after the last turn if any handler threw.
|
|
41
|
+
+ */
|
|
42
|
+
emit(topic: string, payload?: unknown): number {
|
|
43
|
+
+ const captured = [...(this.subscriptions.get(topic) ?? [])];
|
|
44
|
+
+ const errors: unknown[] = [];
|
|
45
|
+
let called = 0;
|
|
46
|
+
- for (const handler of this.handlers.get(topic) ?? []) {
|
|
47
|
+
- handler(payload);
|
|
48
|
+
+ for (const subscription of captured) {
|
|
49
|
+
+ if (subscription.once) {
|
|
50
|
+
+ if (subscription.consumed) continue;
|
|
51
|
+
+ subscription.consumed = true;
|
|
52
|
+
+ this.remove(topic, subscription);
|
|
53
|
+
+ }
|
|
54
|
+
called += 1;
|
|
55
|
+
+ try {
|
|
56
|
+
+ subscription.handler(payload);
|
|
57
|
+
+ } catch (error) {
|
|
58
|
+
+ errors.push(error);
|
|
59
|
+
+ }
|
|
60
|
+
+ }
|
|
61
|
+
+ if (errors.length > 0) {
|
|
62
|
+
+ throw new AggregateError(errors, `${errors.length} handler(s) threw while emitting "${topic}"`);
|
|
63
|
+
}
|
|
64
|
+
return called;
|
|
65
|
+
}
|
|
66
|
+
+
|
|
67
|
+
+ private subscribe(topic: string, handler: Handler, once: boolean): Unsubscribe {
|
|
68
|
+
+ const subscription: Subscription = { handler, once, consumed: false };
|
|
69
|
+
+ const list = this.subscriptions.get(topic) ?? [];
|
|
70
|
+
+ list.push(subscription);
|
|
71
|
+
+ this.subscriptions.set(topic, list);
|
|
72
|
+
+ return () => this.remove(topic, subscription);
|
|
73
|
+
+ }
|
|
74
|
+
+
|
|
75
|
+
+ private remove(topic: string, subscription: Subscription): void {
|
|
76
|
+
+ const list = this.subscriptions.get(topic);
|
|
77
|
+
+ const index = list === undefined ? -1 : list.indexOf(subscription);
|
|
78
|
+
+ if (list === undefined || index === -1) return;
|
|
79
|
+
+ list.splice(index, 1);
|
|
80
|
+
+ if (list.length === 0) this.subscriptions.delete(topic);
|
|
81
|
+
+ }
|
|
82
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/** A small synchronous in-process event bus used by a command-line tool. */
|
|
2
|
+
|
|
3
|
+
export type Handler = (payload: unknown) => void;
|
|
4
|
+
export type Unsubscribe = () => void;
|
|
5
|
+
|
|
6
|
+
export class EventBus {
|
|
7
|
+
private readonly handlers = new Map<string, Handler[]>();
|
|
8
|
+
|
|
9
|
+
/** Register `handler` for exactly `topic`. Handlers run in registration order. */
|
|
10
|
+
on(topic: string, handler: Handler): Unsubscribe {
|
|
11
|
+
const list = this.handlers.get(topic) ?? [];
|
|
12
|
+
list.push(handler);
|
|
13
|
+
this.handlers.set(topic, list);
|
|
14
|
+
// Not implemented yet: there is no way to take a subscription back.
|
|
15
|
+
return () => {};
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** Not implemented yet. */
|
|
19
|
+
once(_topic: string, _handler: Handler): Unsubscribe {
|
|
20
|
+
throw new Error("EventBus.once is not implemented");
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Call every handler registered for `topic` and return how many were called. */
|
|
24
|
+
emit(topic: string, payload?: unknown): number {
|
|
25
|
+
let called = 0;
|
|
26
|
+
for (const handler of this.handlers.get(topic) ?? []) {
|
|
27
|
+
handler(payload);
|
|
28
|
+
called += 1;
|
|
29
|
+
}
|
|
30
|
+
return called;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# Order confirmations can't get an SMS channel or a fast test suite
|
|
2
|
+
|
|
3
|
+
**Reported by:** the notifications team and the storefront on-call rota
|
|
4
|
+
**Component:** `orders`
|
|
5
|
+
|
|
6
|
+
## Symptom
|
|
7
|
+
|
|
8
|
+
Two pieces of work are stuck on the same module:
|
|
9
|
+
|
|
10
|
+
1. Marketing wants confirmations by SMS as well as email. The SMS provider's client is ready, but
|
|
11
|
+
there is nowhere to plug it in: `orders/service.py` imports `SmtpNotifier` and constructs it
|
|
12
|
+
inside `confirm`.
|
|
13
|
+
2. The checkout team's tests take minutes because every `OrderService` test writes a SQLite file
|
|
14
|
+
and an outbox file. They asked for an in-memory setup and were told "the service builds its own
|
|
15
|
+
store, there's nothing to swap".
|
|
16
|
+
|
|
17
|
+
Both teams have proposed copy-pasting `OrderService`. We would rather fix the shape of the module.
|
|
18
|
+
|
|
19
|
+
## Reproduction
|
|
20
|
+
|
|
21
|
+
```python
|
|
22
|
+
from orders.service import OrderService
|
|
23
|
+
|
|
24
|
+
OrderService("orders.db", "outbox.jsonl") # the only way to build one
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
Open `src/orders/service.py`: it imports `orders.sqlite_store` and `orders.smtp_gateway` directly
|
|
28
|
+
and instantiates both on every call.
|
|
29
|
+
|
|
30
|
+
## Expected
|
|
31
|
+
|
|
32
|
+
- `src/orders/ports.py` defines two `typing.Protocol`s, decorated `@runtime_checkable`:
|
|
33
|
+
`OrderRepository` (`save_confirmed(order)`, `get(order_id)`) and `Notifier`
|
|
34
|
+
(`send(to, subject, body)`).
|
|
35
|
+
- `OrderService` takes its collaborators by keyword: `OrderService(repository=..., notifier=...)`.
|
|
36
|
+
It no longer accepts paths and no longer imports `orders.sqlite_store`, `orders.smtp_gateway`,
|
|
37
|
+
`sqlite3` or `smtplib`.
|
|
38
|
+
- `SqliteOrderRepository` and `SmtpNotifier` keep their names and behaviour and satisfy the ports.
|
|
39
|
+
- `orders.app.build_service(db_path, outbox)` is the one place that wires the real adapters, so
|
|
40
|
+
the existing end-to-end tests keep passing unchanged.
|
|
41
|
+
- The confirmation message format, the empty-order rejection and `find` behave exactly as before.
|
|
42
|
+
|
|
43
|
+
## Notes
|
|
44
|
+
|
|
45
|
+
- This is a refactor: no behaviour change is wanted. Visible tests go through `build_service` and
|
|
46
|
+
must stay green throughout.
|
|
47
|
+
- Add a test that builds `OrderService` with in-memory fakes and checks a confirmation end to end,
|
|
48
|
+
without touching the filesystem.
|
|
49
|
+
- Run `pgnd check` from the session workspace to check your changes in Docker.
|
|
50
|
+
- You may use your own editor and AI tools. The goal is a module whose boundaries are explicit,
|
|
51
|
+
not merely tests that pass.
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
spec_version: "0.1"
|
|
2
|
+
id: order-notifications-boundary
|
|
3
|
+
version: 1.0.0
|
|
4
|
+
title: Put a port between the order service and its storage and email
|
|
5
|
+
type: refactor
|
|
6
|
+
summary: >-
|
|
7
|
+
OrderService imports and constructs SQLite and SMTP adapters itself, so nothing can be swapped
|
|
8
|
+
for SMS or for fast tests. Introduce ports, inject the adapters and keep behaviour identical.
|
|
9
|
+
languages: [python]
|
|
10
|
+
tags: [architecture, ports-and-adapters, dependency-inversion, refactoring]
|
|
11
|
+
difficulty: intermediate
|
|
12
|
+
timebox_minutes: 40
|
|
13
|
+
ai_policy: allowed
|
|
14
|
+
environment:
|
|
15
|
+
dockerfile: environment/Dockerfile
|
|
16
|
+
services: []
|
|
17
|
+
test_command: pytest -q -p no:cacheprovider --junitxml=$PG_RESULTS/junit.xml
|
|
18
|
+
checks:
|
|
19
|
+
visible: checks/visible
|
|
20
|
+
hidden: checks/hidden
|
|
21
|
+
solution: solution/reference.patch
|
|
22
|
+
expected_paths: [src/orders/, tests/]
|
|
23
|
+
hints:
|
|
24
|
+
- Draw the arrows. Today service -> sqlite_store and service -> smtp_gateway. Which module should own the interface the service calls, so that the arrows point towards the service instead?
|
|
25
|
+
- A Protocol describes what the service needs; the adapters already have those methods. Make the service receive them instead of building them, and move the construction to app.build_service.
|
|
26
|
+
debrief:
|
|
27
|
+
questions: debrief/questions.md
|
|
28
|
+
focus: [design_judgment, test_quality, scope_judgment]
|
|
29
|
+
author: {name: Fran Delicado, github: franndeli}
|
|
30
|
+
content_license: CC-BY-4.0
|
|
31
|
+
provenance: generated
|
|
32
|
+
generated_by: {model: "Claude (Anthropic) via pi coding agent, model id unverified", prompt_version: launch-boundary-v1}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
"""Architecture checks: the service depends on ports, not on SQLite or SMTP."""
|
|
2
|
+
|
|
3
|
+
import ast
|
|
4
|
+
import importlib
|
|
5
|
+
import inspect
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
import pytest
|
|
9
|
+
|
|
10
|
+
from orders import Order, OrderLine
|
|
11
|
+
|
|
12
|
+
SRC = Path(__file__).resolve().parents[1] / "src" / "orders"
|
|
13
|
+
INFRASTRUCTURE = {"orders.sqlite_store", "orders.smtp_gateway", "sqlite3", "smtplib"}
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def imported_modules(path: Path) -> set[str]:
|
|
17
|
+
tree = ast.parse(path.read_text(encoding="utf-8"))
|
|
18
|
+
found: set[str] = set()
|
|
19
|
+
for node in ast.walk(tree):
|
|
20
|
+
if isinstance(node, ast.Import):
|
|
21
|
+
found.update(alias.name for alias in node.names)
|
|
22
|
+
elif isinstance(node, ast.ImportFrom) and node.module:
|
|
23
|
+
found.add(node.module)
|
|
24
|
+
return found
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def test_service_module_has_no_infrastructure_imports():
|
|
28
|
+
assert not imported_modules(SRC / "service.py") & INFRASTRUCTURE
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def test_ports_module_defines_runtime_checkable_protocols():
|
|
32
|
+
ports = importlib.import_module("orders.ports")
|
|
33
|
+
for name in ("OrderRepository", "Notifier"):
|
|
34
|
+
port = getattr(ports, name)
|
|
35
|
+
assert getattr(port, "_is_protocol", False), f"{name} must be a typing.Protocol"
|
|
36
|
+
assert getattr(port, "_is_runtime_protocol", False), f"{name} must be @runtime_checkable"
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def test_adapters_satisfy_their_ports(tmp_path):
|
|
40
|
+
ports = importlib.import_module("orders.ports")
|
|
41
|
+
from orders.smtp_gateway import SmtpNotifier
|
|
42
|
+
from orders.sqlite_store import SqliteOrderRepository
|
|
43
|
+
|
|
44
|
+
assert isinstance(SqliteOrderRepository(tmp_path / "db"), ports.OrderRepository)
|
|
45
|
+
assert isinstance(SmtpNotifier(tmp_path / "out"), ports.Notifier)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class MemoryRepository:
|
|
49
|
+
def __init__(self):
|
|
50
|
+
self.saved = {}
|
|
51
|
+
|
|
52
|
+
def save_confirmed(self, order):
|
|
53
|
+
self.saved[order.order_id] = order
|
|
54
|
+
|
|
55
|
+
def get(self, order_id):
|
|
56
|
+
return self.saved.get(order_id)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class MemoryNotifier:
|
|
60
|
+
def __init__(self):
|
|
61
|
+
self.sent = []
|
|
62
|
+
|
|
63
|
+
def send(self, to, subject, body):
|
|
64
|
+
self.sent.append((to, subject, body))
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def test_service_takes_injected_ports_by_keyword():
|
|
68
|
+
from orders.service import OrderService
|
|
69
|
+
|
|
70
|
+
parameters = inspect.signature(OrderService).parameters
|
|
71
|
+
assert {"repository", "notifier"} <= set(parameters)
|
|
72
|
+
repository, notifier = MemoryRepository(), MemoryNotifier()
|
|
73
|
+
service = OrderService(repository=repository, notifier=notifier)
|
|
74
|
+
order = Order("o-9", "grace@example.test", (OrderLine("desk", 1, 20000),))
|
|
75
|
+
service.confirm(order)
|
|
76
|
+
assert repository.saved == {"o-9": order}
|
|
77
|
+
assert notifier.sent == [
|
|
78
|
+
("grace@example.test", "Order o-9 confirmed", "Thanks for your order.\n- 1 x desk @ 200.00\nTotal: 200.00")
|
|
79
|
+
]
|
|
80
|
+
assert service.find("o-9") == order
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def test_service_does_not_construct_adapters_itself():
|
|
84
|
+
from orders.service import OrderService
|
|
85
|
+
|
|
86
|
+
with pytest.raises(TypeError):
|
|
87
|
+
OrderService("orders.db", "outbox.jsonl")
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import json
|
|
2
|
+
|
|
3
|
+
from orders import Order, OrderLine
|
|
4
|
+
from orders.app import build_service
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def order(order_id="o-1"):
|
|
8
|
+
return Order(order_id, "ada@example.test", (OrderLine("chair", 2, 4999), OrderLine("lamp", 1, 1500)))
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def test_confirm_persists_and_emails(tmp_path):
|
|
12
|
+
outbox = tmp_path / "outbox.jsonl"
|
|
13
|
+
service = build_service(str(tmp_path / "orders.db"), str(outbox))
|
|
14
|
+
service.confirm(order())
|
|
15
|
+
assert service.find("o-1") == order()
|
|
16
|
+
sent = [json.loads(line) for line in outbox.read_text().splitlines()]
|
|
17
|
+
assert sent == [
|
|
18
|
+
{
|
|
19
|
+
"to": "ada@example.test",
|
|
20
|
+
"subject": "Order o-1 confirmed",
|
|
21
|
+
"body": "Thanks for your order.\n- 2 x chair @ 49.99\n- 1 x lamp @ 15.00\nTotal: 114.98",
|
|
22
|
+
}
|
|
23
|
+
]
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def test_empty_order_is_rejected_before_any_side_effect(tmp_path):
|
|
27
|
+
outbox = tmp_path / "outbox.jsonl"
|
|
28
|
+
service = build_service(str(tmp_path / "orders.db"), str(outbox))
|
|
29
|
+
try:
|
|
30
|
+
service.confirm(Order("o-2", "ada@example.test", ()))
|
|
31
|
+
except ValueError:
|
|
32
|
+
pass
|
|
33
|
+
else:
|
|
34
|
+
raise AssertionError("expected ValueError")
|
|
35
|
+
assert service.find("o-2") is None
|
|
36
|
+
assert not outbox.exists()
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def test_unknown_order_is_none(tmp_path):
|
|
40
|
+
service = build_service(str(tmp_path / "orders.db"), str(tmp_path / "outbox.jsonl"))
|
|
41
|
+
assert service.find("missing") is None
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
- Which dependency direction did you reverse, and what now knows about SQLite and SMTP?
|
|
2
|
+
- Why do the protocols belong to the service's side of the boundary rather than next to the adapters?
|
|
3
|
+
- What did you keep unchanged on purpose, and how did the visible tests help you prove it?
|
|
4
|
+
- Where would an SMS notifier go, and what, if anything, in `service.py` would have to change?
|
|
5
|
+
- If an AI assistant proposed the refactor, what did you verify yourself before accepting it?
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
diff --git a/src/orders/app.py b/src/orders/app.py
|
|
2
|
+
index 5ffe407..f814423 100644
|
|
3
|
+
--- a/src/orders/app.py
|
|
4
|
+
+++ b/src/orders/app.py
|
|
5
|
+
@@ -3,7 +3,9 @@
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from orders.service import OrderService
|
|
9
|
+
+from orders.smtp_gateway import SmtpNotifier
|
|
10
|
+
+from orders.sqlite_store import SqliteOrderRepository
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def build_service(db_path: str, outbox: str) -> OrderService:
|
|
14
|
+
- return OrderService(db_path, outbox)
|
|
15
|
+
+ return OrderService(repository=SqliteOrderRepository(db_path), notifier=SmtpNotifier(outbox))
|
|
16
|
+
diff --git a/src/orders/ports.py b/src/orders/ports.py
|
|
17
|
+
new file mode 100644
|
|
18
|
+
index 0000000..437915e
|
|
19
|
+
--- /dev/null
|
|
20
|
+
+++ b/src/orders/ports.py
|
|
21
|
+
@@ -0,0 +1,19 @@
|
|
22
|
+
+"""What the order service needs from the outside world, as protocols."""
|
|
23
|
+
+
|
|
24
|
+
+from __future__ import annotations
|
|
25
|
+
+
|
|
26
|
+
+from typing import Protocol, runtime_checkable
|
|
27
|
+
+
|
|
28
|
+
+from orders.models import Order
|
|
29
|
+
+
|
|
30
|
+
+
|
|
31
|
+
+@runtime_checkable
|
|
32
|
+
+class OrderRepository(Protocol):
|
|
33
|
+
+ def save_confirmed(self, order: Order) -> None: ...
|
|
34
|
+
+
|
|
35
|
+
+ def get(self, order_id: str) -> Order | None: ...
|
|
36
|
+
+
|
|
37
|
+
+
|
|
38
|
+
+@runtime_checkable
|
|
39
|
+
+class Notifier(Protocol):
|
|
40
|
+
+ def send(self, to: str, subject: str, body: str) -> None: ...
|
|
41
|
+
diff --git a/src/orders/service.py b/src/orders/service.py
|
|
42
|
+
index 91267c6..d5446de 100644
|
|
43
|
+
--- a/src/orders/service.py
|
|
44
|
+
+++ b/src/orders/service.py
|
|
45
|
+
@@ -3,8 +3,7 @@
|
|
46
|
+
from __future__ import annotations
|
|
47
|
+
|
|
48
|
+
from orders.models import Order
|
|
49
|
+
-from orders.smtp_gateway import SmtpNotifier
|
|
50
|
+
-from orders.sqlite_store import SqliteOrderRepository
|
|
51
|
+
+from orders.ports import Notifier, OrderRepository
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def format_confirmation(order: Order) -> tuple[str, str]:
|
|
55
|
+
@@ -16,16 +15,16 @@ def format_confirmation(order: Order) -> tuple[str, str]:
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class OrderService:
|
|
59
|
+
- def __init__(self, db_path: str, outbox: str) -> None:
|
|
60
|
+
- self._db_path = db_path
|
|
61
|
+
- self._outbox = outbox
|
|
62
|
+
+ def __init__(self, *, repository: OrderRepository, notifier: Notifier) -> None:
|
|
63
|
+
+ self._repository = repository
|
|
64
|
+
+ self._notifier = notifier
|
|
65
|
+
|
|
66
|
+
def confirm(self, order: Order) -> None:
|
|
67
|
+
if not order.lines:
|
|
68
|
+
raise ValueError("an order needs at least one line")
|
|
69
|
+
- SqliteOrderRepository(self._db_path).save_confirmed(order)
|
|
70
|
+
+ self._repository.save_confirmed(order)
|
|
71
|
+
subject, body = format_confirmation(order)
|
|
72
|
+
- SmtpNotifier(self._outbox).send(order.customer_email, subject, body)
|
|
73
|
+
+ self._notifier.send(order.customer_email, subject, body)
|
|
74
|
+
|
|
75
|
+
def find(self, order_id: str) -> Order | None:
|
|
76
|
+
- return SqliteOrderRepository(self._db_path).get(order_id)
|
|
77
|
+
+ return self._repository.get(order_id)
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
@dataclass(frozen=True)
|
|
7
|
+
class OrderLine:
|
|
8
|
+
sku: str
|
|
9
|
+
quantity: int
|
|
10
|
+
unit_price_cents: int
|
|
11
|
+
|
|
12
|
+
@property
|
|
13
|
+
def total_cents(self) -> int:
|
|
14
|
+
return self.quantity * self.unit_price_cents
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass(frozen=True)
|
|
18
|
+
class Order:
|
|
19
|
+
order_id: str
|
|
20
|
+
customer_email: str
|
|
21
|
+
lines: tuple[OrderLine, ...] = field(default_factory=tuple)
|
|
22
|
+
|
|
23
|
+
@property
|
|
24
|
+
def total_cents(self) -> int:
|
|
25
|
+
return sum(line.total_cents for line in self.lines)
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"""Confirm orders: persist them and tell the customer."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from orders.models import Order
|
|
6
|
+
from orders.smtp_gateway import SmtpNotifier
|
|
7
|
+
from orders.sqlite_store import SqliteOrderRepository
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def format_confirmation(order: Order) -> tuple[str, str]:
|
|
11
|
+
"""Subject and body of the confirmation message. Shared by every channel."""
|
|
12
|
+
subject = f"Order {order.order_id} confirmed"
|
|
13
|
+
lines = [f"- {line.quantity} x {line.sku} @ {line.unit_price_cents / 100:.2f}" for line in order.lines]
|
|
14
|
+
body = "\n".join(["Thanks for your order.", *lines, f"Total: {order.total_cents / 100:.2f}"])
|
|
15
|
+
return subject, body
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class OrderService:
|
|
19
|
+
def __init__(self, db_path: str, outbox: str) -> None:
|
|
20
|
+
self._db_path = db_path
|
|
21
|
+
self._outbox = outbox
|
|
22
|
+
|
|
23
|
+
def confirm(self, order: Order) -> None:
|
|
24
|
+
if not order.lines:
|
|
25
|
+
raise ValueError("an order needs at least one line")
|
|
26
|
+
SqliteOrderRepository(self._db_path).save_confirmed(order)
|
|
27
|
+
subject, body = format_confirmation(order)
|
|
28
|
+
SmtpNotifier(self._outbox).send(order.customer_email, subject, body)
|
|
29
|
+
|
|
30
|
+
def find(self, order_id: str) -> Order | None:
|
|
31
|
+
return SqliteOrderRepository(self._db_path).get(order_id)
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"""Email delivery through the storefront's SMTP relay.
|
|
2
|
+
|
|
3
|
+
The relay is not reachable from developer machines, so this gateway writes each message to an
|
|
4
|
+
outbox file; production swaps in the real transport by configuration.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class SmtpNotifier:
|
|
14
|
+
def __init__(self, outbox: str | Path) -> None:
|
|
15
|
+
self._outbox = Path(outbox)
|
|
16
|
+
|
|
17
|
+
def send(self, to: str, subject: str, body: str) -> None:
|
|
18
|
+
with self._outbox.open("a", encoding="utf-8") as stream:
|
|
19
|
+
stream.write(json.dumps({"to": to, "subject": subject, "body": body}) + "\n")
|