diplomail (Stage F): docs + edge-case tests + LibreTranslate recipe
Closes the documentation gaps from the freshly-audited diplomail implementation. FUNCTIONAL.md gains a §11 "Diplomatic mail" with the full user-facing story across all five stages, mirrored into FUNCTIONAL_ru.md as the project conventions require. A new backend/docs/diplomail-translator-setup.md captures the LibreTranslate operational recipe (Docker image, env wiring, manual smoke test, troubleshooting). The package README gains a "Multi-instance posture" note documenting the deliberate absence of FOR UPDATE in the worker pickup query — single-instance is safe today; multi-instance scaling will revisit the claim mechanism. Two small edge-case tests round things out: malformed LibreTranslate response bodies (single string, short array, empty array, missing field) must surface as errors so the worker falls back instead of crashing; and an empty translation queue must produce zero events on three consecutive Worker.Tick calls. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -136,6 +136,29 @@ through standard OpenTelemetry export — translation outcomes
|
||||
surface in `diplomail.worker` logs at Info / Warn levels;
|
||||
Grafana / Prometheus dashboards live outside this package.
|
||||
|
||||
### Multi-instance posture (known limitation)
|
||||
|
||||
`PickPendingTranslationPair` intentionally drops `FOR UPDATE`: the
|
||||
worker is single-threaded per process, and we did not want a slow
|
||||
LibreTranslate HTTP call to keep a row-lock open. The cost is a
|
||||
small window where two backend instances pulling at the same
|
||||
moment can both claim the same pair: the cache-write side stays
|
||||
clean (`INSERT … ON CONFLICT DO NOTHING`), but each instance will
|
||||
publish its own push event to every recipient of the pair, so the
|
||||
duplicate push is the visible failure mode.
|
||||
|
||||
The current deployment runs a single backend instance and the
|
||||
window does not exist. When the platform scales to multiple
|
||||
instances, we will revisit the pickup query — either by holding
|
||||
the lock through the HTTP call (with a short timeout to bound the
|
||||
worst case) or by introducing a `claimed_at` column and a
|
||||
short-lived advisory lease. The change is local to this package
|
||||
and does not affect callers.
|
||||
|
||||
For the LibreTranslate operational recipe — installing, wiring,
|
||||
manual smoke test — see
|
||||
[`backend/docs/diplomail-translator-setup.md`](../../docs/diplomail-translator-setup.md).
|
||||
|
||||
## Push integration
|
||||
|
||||
Every successful send emits a `diplomail.message.received` push
|
||||
|
||||
@@ -808,6 +808,36 @@ func TestDiplomailLifecycleMembershipKick(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestDiplomailWorkerTickOnEmptyQueueIsNoop confirms the async
|
||||
// worker tolerates an empty pending queue: no error, no panic, no
|
||||
// publisher events. Belt-and-suspenders for the case where backend
|
||||
// starts, mounts the worker as an `app.Component`, and ticks before
|
||||
// any user has sent mail.
|
||||
func TestDiplomailWorkerTickOnEmptyQueueIsNoop(t *testing.T) {
|
||||
db := startPostgres(t)
|
||||
ctx := context.Background()
|
||||
|
||||
publisher := &recordingPublisher{}
|
||||
svc := diplomail.NewService(diplomail.Deps{
|
||||
Store: diplomail.NewStore(db),
|
||||
Memberships: &staticMembershipLookup{},
|
||||
Notification: publisher,
|
||||
Config: config.DiplomailConfig{
|
||||
MaxBodyBytes: 4096,
|
||||
MaxSubjectBytes: 256,
|
||||
},
|
||||
})
|
||||
worker := diplomail.NewWorker(svc)
|
||||
for i := 0; i < 3; i++ {
|
||||
if err := worker.Tick(ctx); err != nil {
|
||||
t.Fatalf("tick %d on empty queue: %v", i, err)
|
||||
}
|
||||
}
|
||||
if got := publisher.snapshot(); len(got) != 0 {
|
||||
t.Fatalf("publisher fired %d events on empty queue", len(got))
|
||||
}
|
||||
}
|
||||
|
||||
// TestDiplomailAsyncTranslationDelivery covers the Stage E flow:
|
||||
// 1. SendPersonal where recipient.preferred_language != body_lang
|
||||
// materialises a recipient with `AvailableAt == nil`; the inbox
|
||||
|
||||
@@ -139,3 +139,35 @@ func TestLibreTranslateRequiresURL(t *testing.T) {
|
||||
t.Fatalf("expected error for empty URL")
|
||||
}
|
||||
}
|
||||
|
||||
// TestLibreTranslateRejectsMalformedArray defends against a server
|
||||
// that returns a partial / unexpected `translatedText` payload. The
|
||||
// client must surface an error (not panic, not return a half-empty
|
||||
// Result) so the worker can decide between retry and fallback.
|
||||
func TestLibreTranslateRejectsMalformedArray(t *testing.T) {
|
||||
t.Parallel()
|
||||
cases := []struct {
|
||||
name string
|
||||
body string
|
||||
}{
|
||||
{"single string", `{"translatedText": "only one"}`},
|
||||
{"array of one", `{"translatedText": ["only one"]}`},
|
||||
{"empty array", `{"translatedText": []}`},
|
||||
{"missing field", `{"foo":"bar"}`},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
body := tc.body
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
_, _ = w.Write([]byte(body))
|
||||
}))
|
||||
t.Cleanup(server.Close)
|
||||
tr, _ := NewLibreTranslate(LibreTranslateConfig{URL: server.URL})
|
||||
res, err := tr.Translate(context.Background(), "en", "ru", "subject", "body")
|
||||
if err == nil {
|
||||
t.Fatalf("expected error for malformed body %q, got %+v", body, res)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user