M365-03: Outlook-Kalender-Kopplung Fristen/Wiedervorlage #245

Merged
patrick merged 1 commits from feature/m365-03-03-outlook-kalender-kopplung-fristen-wiedervorlage into main 2026-09-28 06:30:15 +02:00
9 changed files with 792 additions and 5 deletions

No files matched your search

+40
View File
@@ -0,0 +1,40 @@
# M365-03 – Prüfprotokoll: Outlook-Kalender-Kopplung (Fristen/Wiedervorlage)
Datum: 2026-09-28. Host: Entwicklungshost 192.168.1.204, eigene Wegwerf-DB/-Rolle `m365_03_test` (angelegt für diesen Testlauf, keine Kollision mit anderen parallelen Agenten). Frischer Klon von `origin/main` in eigenem Wegwerf-Arbeitsverzeichnis.
## Umfang
- `connect/internal/m365calsync`: Kopplung von NEXARCH-Fristen/Wiedervorlagen mit dem Outlook-Kalender über Microsoft Graph (M365-01, `m365connector.Client.Send`). Push-gerichtetes Gegenstück zu `m365docsync` (M365-02): NEXARCH ist die führende Seite, Outlook folgt — statt Konflikterkennung reconciled `Store.Sync` den Outlook-Kalender auf den aktuellen NEXARCH-Stand.
- `Store.Sync`: neue Frist/Wiedervorlage -> Kalendereintrag in Outlook angelegt (AC1). Geänderte Frist (Titel/Beschreibung/Fälligkeit) -> bestehender Eintrag aktualisiert statt dupliziert (AC2). Frist nicht mehr in der Quelle vorhanden -> Kalendereintrag gelöscht und Tracking-Datensatz entfernt (AC3). Änderungserkennung über einen Inhalts-Hash je Frist, analog zum Hash-Vergleich in `m365docsync`.
- `GraphTarget`: rendert `CalendarEvent` in Microsoft Graphs Kalenderereignis-Format und ruft `POST/PATCH/DELETE /users/{id}/events(/{eventId})` über die neue generische Schreib-Primitive `m365connector.Client.Send` auf.
- `m365connector.Client.Send`: kleine Erweiterung von M365-01 um eine generische Schreib-Operation (POST/PATCH/DELETE) neben der bestehenden `Read` (GET) — gleiche Fehlerbehandlung (`ErrPermissionExpired`/`ErrConnectionFailed`), keine Sonderbehandlung für Microsoft 365.
- Migration `0012_m365_calendar_events`: Tabelle `m365_calendar_events` mit Fälligkeit, Titel, Inhalts-Hash und der von Graph zurückgegebenen Ereignis-Id je Frist/Wiedervorlage.
- `connect/scripts/check-security-test-coverage.sh`: `internal/m365calsync` in die Liste sicherheitsrelevanter Pakete mit Testpflicht aufgenommen (Tenant-gescopte Daten).
## Limitation: kein echter Microsoft-Testmandant
Wie bereits bei M365-01/M365-02 dokumentiert stehen in dieser Umgebung keine echten Azure-AD/Microsoft-365-Testmandanten-Credentials zur Verfügung. Die Graph-Kalender-Aufrufe (`POST/PATCH/DELETE /users/{id}/events`) werden gegen einen `httptest.Server`-Mock geprüft, der den realen Antwortvertrag nachbildet (JSON-Event-Body, von Graph vergebene Ereignis-Id, 204 bei Löschung). Ein echter End-to-End-Lauf gegen einen produktiven Microsoft-Testmandanten bzw. ein echtes Outlook-Postfach ist damit **offen** und ehrlich als solcher dokumentiert, nicht übersprungen. Die "Prüfungen vor der Abnahme" unten sind entsprechend gegen das Mock-Postfach durchgeführt, nicht gegen ein echtes Test-Outlook.
## Akzeptanzkriterien
| # | Kriterium | Ergebnis |
|---|---|---|
| 1 | Frist/Wiedervorlage erscheint als Kalendereintrag in Outlook | **Erfüllt.** `TestSync_NewDeadline_CreatesCalendarEvent` (neuer Kalendereintrag mit korrektem Titel wird angelegt, Tracking-Datensatz mit Graph-Ereignis-Id gespeichert) + `TestGraphTarget_CreateEvent_PostsToGraphEventsEndpoint` (echter HTTP-Mock-Aufruf gegen `POST /users/{id}/events`). |
| 2 | Änderung der Frist in NEXARCH aktualisiert den Kalendereintrag | **Erfüllt.** `TestSync_ChangedDeadline_UpdatesExistingCalendarEvent` (geänderte Fälligkeit löst Update auf denselben Eintrag aus, kein Duplikat) + `TestGraphTarget_UpdateEvent_PatchesExistingEvent` (echter HTTP-Mock-Aufruf gegen `PATCH .../events/{id}`). `TestSync_UnchangedDeadline_IsNoop` als Gegenprobe: unveränderte Frist löst keinen weiteren Push aus. |
| 3 | Gelöschte Frist entfernt den zugehörigen Kalendereintrag | **Erfüllt.** `TestSync_DeletedDeadline_RemovesCalendarEvent` (Frist verschwindet aus der Quelle -> Kalendereintrag gelöscht, Tracking-Datensatz entfernt) + `TestGraphTarget_DeleteEvent_DeletesExistingEvent` (echter HTTP-Mock-Aufruf gegen `DELETE .../events/{id}`). |
## Prüfungen vor der Abnahme
1. **Testfrist im Test-Outlook-Kalender korrekt sichtbar.** Zielwert: nach `Sync` existiert ein Kalendereintrag mit dem Titel und der Fälligkeit der Frist, plus ein `m365_calendar_events`-Datensatz mit der von Graph vergebenen Ereignis-Id. Ergebnis: **bestanden** (gegen das Mock-Postfach, siehe Limitation oben) — `TestSync_NewDeadline_CreatesCalendarEvent`, `TestGraphTarget_CreateEvent_PostsToGraphEventsEndpoint`.
2. **Friständerung im Test korrekt im Kalender nachgezogen.** Zielwert: eine geänderte Fälligkeit/Titel führt zu einem `PATCH` auf denselben Graph-Ereignis-Eintrag, kein zweiter Eintrag entsteht. Ergebnis: **bestanden** — `TestSync_ChangedDeadline_UpdatesExistingCalendarEvent`, `TestGraphTarget_UpdateEvent_PatchesExistingEvent`.
3. **Löschung im Test entfernt Kalendereintrag vollständig.** Zielwert: eine aus der Quelle verschwundene Frist führt zu `DELETE` auf den zugehörigen Graph-Ereignis-Eintrag und Entfernen des Tracking-Datensatzes. Ergebnis: **bestanden** — `TestSync_DeletedDeadline_RemovesCalendarEvent`, `TestGraphTarget_DeleteEvent_DeletesExistingEvent`.
## Regressionsprüfung
`go build ./...`, `go vet ./...` für das gesamte `connect`-Modul: keine Fehler. `go test ./internal/m365calsync/... -v -p 1` (gegen `TEST_TENANT_DSN` der Wegwerf-DB `m365_03_test`): alle 7 Tests grün. Zusätzlich `go test ./internal/m365connector/... ./internal/m365docsync/...` erneut ausgeführt, um die Erweiterung von `m365connector.Client` um `Send` nicht zu regressieren: alle bestehenden Tests weiterhin grün. `scripts/check-security-test-coverage.sh`: Merge-Gate bestanden, `internal/m365calsync` mit Testpflicht erfasst.
## Nicht Bestandteil dieser Kachel (nicht umgesetzt)
- `QA-03` (Prüfgate ERP/DATEV/M365) — laut Ticket ausdrücklich eine andere Kachel; die Schnittstelle (`Store`, `Source`, `Target`, klar typisierte Fehler) ist so gehalten, dass ein späteres Prüfgate ohne Umbau darauf aufbauen kann.
- Anbindung an die konkreten Datenquellen TSK-02 (Fristen-Kalenderlogik) und VOR-03 (Wiedervorlage-Akte-Objekt-Terminverknüpfung): `Source` ist die Schnittstelle, über die ein Aufrufer die aktuellen Fristen/Wiedervorlagen liefert (gleiches Muster wie `m365docsync.Source` für SharePoint/OneDrive) — welches Modul sie konkret implementiert und mit welchem Scheduling (`syncjob`, analog zu M365-02s `Runner`) ist nicht Teil dieser Kachel, da im Ticket nicht als Akzeptanzkriterium gefordert.
- Automatischer Hintergrund-Dispatcher/Scheduler für `Sync` — gleiche dokumentierte Lücke wie bei `m365docsync.Runner`, `masterdatasync.Runner` und `erpexport.Store.ClaimDueRetries`.
+106
View File
@@ -0,0 +1,106 @@
package m365calsync
import (
"context"
"encoding/json"
"fmt"
"time"
"gitea.perlbach24.de/scripte/nexarch/connect/internal/credential"
)
// GraphSender is the minimal capability GraphTarget needs from
// m365connector.Client: an authenticated raw Graph call, read or write.
// Kept as an interface (rather than importing *m365connector.Client
// directly) so the dependency stays the same direction as m365docsync's
// GraphReader.
type GraphSender interface {
Send(ctx context.Context, secret credential.Secret, method, path string, body []byte) ([]byte, error)
}
// GraphTarget pushes Deadline/Wiedervorlage events into one user's Outlook
// calendar via Microsoft Graph's calendar-event endpoints, and implements
// Target for Sync.
type GraphTarget struct {
Graph GraphSender
Secret credential.Secret
UserID string
}
func (g GraphTarget) eventsPath() string {
return "/users/" + g.UserID + "/events"
}
func (g GraphTarget) eventPath(externalEventID string) string {
return g.eventsPath() + "/" + externalEventID
}
type graphDateTimeTimeZone struct {
DateTime string `json:"dateTime"`
TimeZone string `json:"timeZone"`
}
type graphEventBody struct {
Subject string `json:"subject"`
Body struct {
ContentType string `json:"contentType"`
Content string `json:"content"`
} `json:"body"`
Start graphDateTimeTimeZone `json:"start"`
End graphDateTimeTimeZone `json:"end"`
}
func toGraphEvent(e CalendarEvent) graphEventBody {
var out graphEventBody
out.Subject = e.Subject
out.Body.ContentType = "text"
out.Body.Content = e.Body
out.Start = graphDateTimeTimeZone{DateTime: e.Start.UTC().Format(time.RFC3339), TimeZone: "UTC"}
out.End = graphDateTimeTimeZone{DateTime: e.End.UTC().Format(time.RFC3339), TimeZone: "UTC"}
return out
}
// CreateEvent creates a new Outlook calendar event via
// POST /users/{id}/events and returns Graph's event id (AC1).
func (g GraphTarget) CreateEvent(ctx context.Context, e CalendarEvent) (string, error) {
payload, err := json.Marshal(toGraphEvent(e))
if err != nil {
return "", fmt.Errorf("m365calsync: create event: encode: %w", err)
}
respBody, err := g.Graph.Send(ctx, g.Secret, "POST", g.eventsPath(), payload)
if err != nil {
return "", fmt.Errorf("m365calsync: create event: %w", err)
}
var created struct {
ID string `json:"id"`
}
if err := json.Unmarshal(respBody, &created); err != nil {
return "", fmt.Errorf("m365calsync: create event: decode response: %w", err)
}
if created.ID == "" {
return "", fmt.Errorf("m365calsync: create event: empty id in response")
}
return created.ID, nil
}
// UpdateEvent updates an existing Outlook calendar event via
// PATCH /users/{id}/events/{eventId} (AC2).
func (g GraphTarget) UpdateEvent(ctx context.Context, externalEventID string, e CalendarEvent) error {
payload, err := json.Marshal(toGraphEvent(e))
if err != nil {
return fmt.Errorf("m365calsync: update event: encode: %w", err)
}
if _, err := g.Graph.Send(ctx, g.Secret, "PATCH", g.eventPath(externalEventID), payload); err != nil {
return fmt.Errorf("m365calsync: update event: %w", err)
}
return nil
}
// DeleteEvent removes an Outlook calendar event via
// DELETE /users/{id}/events/{eventId} (AC3).
func (g GraphTarget) DeleteEvent(ctx context.Context, externalEventID string) error {
if _, err := g.Graph.Send(ctx, g.Secret, "DELETE", g.eventPath(externalEventID), nil); err != nil {
return fmt.Errorf("m365calsync: delete event: %w", err)
}
return nil
}
@@ -0,0 +1,127 @@
package m365calsync
import (
"bytes"
"context"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"testing"
"time"
"gitea.perlbach24.de/scripte/nexarch/connect/internal/credential"
)
// mockGraphCalendar simulates Microsoft Graph's calendar-event endpoints,
// since a real Microsoft-365-Testmandant is not available in this
// environment (same documented limitation as M365-01/M365-02, see
// connect/docs/M365-03-PRUEFPROTOKOLL.md).
func mockGraphCalendar(t *testing.T) (*httptest.Server, *[]string) {
t.Helper()
var calls []string
mux := http.NewServeMux()
mux.HandleFunc("/users/user-1/events", func(w http.ResponseWriter, r *http.Request) {
calls = append(calls, r.Method+" "+r.URL.Path)
if r.Method != http.MethodPost {
w.WriteHeader(http.StatusMethodNotAllowed)
return
}
var body graphEventBody
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
w.WriteHeader(http.StatusBadRequest)
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
w.Write([]byte(`{"id":"AAMk-graph-event-1","subject":"` + body.Subject + `"}`))
})
mux.HandleFunc("/users/user-1/events/AAMk-graph-event-1", func(w http.ResponseWriter, r *http.Request) {
calls = append(calls, r.Method+" "+r.URL.Path)
switch r.Method {
case http.MethodPatch:
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`{"id":"AAMk-graph-event-1"}`))
case http.MethodDelete:
w.WriteHeader(http.StatusNoContent)
default:
w.WriteHeader(http.StatusMethodNotAllowed)
}
})
srv := httptest.NewServer(mux)
t.Cleanup(srv.Close)
return srv, &calls
}
// httpGraphSender calls the mock server directly, in the same
// method/path/body shape m365connector.Client.Send exposes (already
// covered by M365-01's own tests, not re-tested here).
type httpGraphSender struct{ baseURL string }
func (s httpGraphSender) Send(ctx context.Context, secret credential.Secret, method, path string, body []byte) ([]byte, error) {
var reqBody io.Reader
if body != nil {
reqBody = bytes.NewReader(body)
}
req, err := http.NewRequestWithContext(ctx, method, s.baseURL+path, reqBody)
if err != nil {
return nil, err
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
return io.ReadAll(resp.Body)
}
// Pruefung 1 / AC1: CreateEvent legt ueber Graph einen neuen
// Kalendereintrag an und liefert dessen Graph-Id zurueck.
func TestGraphTarget_CreateEvent_PostsToGraphEventsEndpoint(t *testing.T) {
srv, calls := mockGraphCalendar(t)
target := GraphTarget{Graph: httpGraphSender{baseURL: srv.URL}, Secret: credential.Secret{AccessToken: "tok"}, UserID: "user-1"}
id, err := target.CreateEvent(context.Background(), CalendarEvent{
Subject: "Frist Testmandant", Body: "Testfrist", Start: time.Now(), End: time.Now(),
})
if err != nil {
t.Fatalf("CreateEvent() = %v, want nil", err)
}
if id != "AAMk-graph-event-1" {
t.Fatalf("id = %q, want AAMk-graph-event-1", id)
}
if len(*calls) != 1 || (*calls)[0] != "POST /users/user-1/events" {
t.Fatalf("calls = %v, want single POST to events endpoint", *calls)
}
}
// Pruefung 2 / AC2: UpdateEvent aktualisiert den bestehenden Eintrag ueber
// PATCH auf dessen Graph-Id statt einen neuen anzulegen.
func TestGraphTarget_UpdateEvent_PatchesExistingEvent(t *testing.T) {
srv, calls := mockGraphCalendar(t)
target := GraphTarget{Graph: httpGraphSender{baseURL: srv.URL}, Secret: credential.Secret{AccessToken: "tok"}, UserID: "user-1"}
err := target.UpdateEvent(context.Background(), "AAMk-graph-event-1", CalendarEvent{
Subject: "Frist verschoben", Body: "", Start: time.Now(), End: time.Now(),
})
if err != nil {
t.Fatalf("UpdateEvent() = %v, want nil", err)
}
if len(*calls) != 1 || (*calls)[0] != "PATCH /users/user-1/events/AAMk-graph-event-1" {
t.Fatalf("calls = %v, want single PATCH to the event", *calls)
}
}
// Pruefung 3 / AC3: DeleteEvent entfernt den Kalendereintrag ueber DELETE
// auf dessen Graph-Id.
func TestGraphTarget_DeleteEvent_DeletesExistingEvent(t *testing.T) {
srv, calls := mockGraphCalendar(t)
target := GraphTarget{Graph: httpGraphSender{baseURL: srv.URL}, Secret: credential.Secret{AccessToken: "tok"}, UserID: "user-1"}
if err := target.DeleteEvent(context.Background(), "AAMk-graph-event-1"); err != nil {
t.Fatalf("DeleteEvent() = %v, want nil", err)
}
if len(*calls) != 1 || (*calls)[0] != "DELETE /users/user-1/events/AAMk-graph-event-1" {
t.Fatalf("calls = %v, want single DELETE to the event", *calls)
}
}
+261
View File
@@ -0,0 +1,261 @@
// Package m365calsync implements M365-03: coupling of NEXARCH deadlines
// and Wiedervorlagen (recall dates) with the Outlook calendar over the
// Microsoft-Graph connector (M365-01). Push-directed counterpart to
// m365docsync (M365-02): NEXARCH is the leading side here, Outlook
// follows, so instead of a hash-conflict model, Sync creates, updates and
// deletes the Outlook event to match the current NEXARCH state.
//
// AC1: a deadline/Wiedervorlage unseen so far gets a new Outlook calendar
// event created for it.
// AC2: a changed deadline (title/description/due time) updates the
// existing Outlook event instead of creating a duplicate.
// AC3: a deadline no longer present in the source (deleted in NEXARCH)
// has its Outlook event deleted and its tracking record removed.
package m365calsync
import (
"context"
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"time"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
)
var ErrNotFound = errors.New("m365calsync: not found")
// Ref identifies the connector instance a set of synced calendar events
// belongs to.
type Ref struct {
TenantSlug string
ConnectorName string
ConnectorInstanceID string
}
// Deadline is one NEXARCH-side Frist/Wiedervorlage as read from its
// owning module (TSK-02/VOR-03), the minimal fields needed to render an
// Outlook calendar event.
type Deadline struct {
DeadlineID string
Title string
Description string
DueAt time.Time
}
func (d Deadline) contentHash() string {
sum := sha256.Sum256([]byte(d.Title + "\x00" + d.Description + "\x00" + d.DueAt.UTC().Format(time.RFC3339)))
return hex.EncodeToString(sum[:])
}
// Source lists every currently active deadline/Wiedervorlage that should
// have a matching Outlook event. A deadline no longer returned here is
// treated as deleted (AC3).
type Source interface {
ListDeadlines(ctx context.Context) ([]Deadline, error)
}
// CalendarEvent is the Graph-facing rendering of a Deadline.
type CalendarEvent struct {
Subject string
Body string
Start time.Time
End time.Time
}
// Target is the minimal capability Sync needs to push calendar events to
// Outlook. GraphTarget implements it via Microsoft Graph's calendar-event
// endpoints.
type Target interface {
CreateEvent(ctx context.Context, e CalendarEvent) (externalEventID string, err error)
UpdateEvent(ctx context.Context, externalEventID string, e CalendarEvent) error
DeleteEvent(ctx context.Context, externalEventID string) error
}
// Record is the stored sync state for one deadline's Outlook event.
type Record struct {
ID int64
Ref Ref
DeadlineID string
ExternalEventID string
Title string
DueAt time.Time
ContentHash string
}
// Result summarizes one Sync call.
type Result struct {
Created int
Updated int
Deleted int
}
// Store persists calendar sync state in Postgres.
type Store struct {
pool *pgxpool.Pool
}
func NewStore(pool *pgxpool.Pool) *Store {
return &Store{pool: pool}
}
// Get returns the stored record for one deadline, or ErrNotFound.
func (s *Store) Get(ctx context.Context, ref Ref, deadlineID string) (Record, error) {
var r Record
err := s.pool.QueryRow(ctx, `
SELECT id, tenant_slug, connector_name, connector_instance_id, deadline_id,
external_event_id, title, due_at, content_hash
FROM m365_calendar_events
WHERE tenant_slug = $1 AND connector_name = $2 AND connector_instance_id = $3 AND deadline_id = $4
`, ref.TenantSlug, ref.ConnectorName, ref.ConnectorInstanceID, deadlineID).Scan(
&r.ID, &r.Ref.TenantSlug, &r.Ref.ConnectorName, &r.Ref.ConnectorInstanceID, &r.DeadlineID,
&r.ExternalEventID, &r.Title, &r.DueAt, &r.ContentHash,
)
if errors.Is(err, pgx.ErrNoRows) {
return Record{}, ErrNotFound
}
if err != nil {
return Record{}, fmt.Errorf("m365calsync: get: %w", err)
}
return r, nil
}
// ListByRef returns every stored record for a connector instance, for
// inspection/testing.
func (s *Store) ListByRef(ctx context.Context, ref Ref) ([]Record, error) {
rows, err := s.pool.Query(ctx, `
SELECT id, tenant_slug, connector_name, connector_instance_id, deadline_id,
external_event_id, title, due_at, content_hash
FROM m365_calendar_events
WHERE tenant_slug = $1 AND connector_name = $2 AND connector_instance_id = $3
ORDER BY deadline_id
`, ref.TenantSlug, ref.ConnectorName, ref.ConnectorInstanceID)
if err != nil {
return nil, fmt.Errorf("m365calsync: list: %w", err)
}
defer rows.Close()
var out []Record
for rows.Next() {
var r Record
if err := rows.Scan(&r.ID, &r.Ref.TenantSlug, &r.Ref.ConnectorName, &r.Ref.ConnectorInstanceID, &r.DeadlineID,
&r.ExternalEventID, &r.Title, &r.DueAt, &r.ContentHash); err != nil {
return nil, fmt.Errorf("m365calsync: list: scan: %w", err)
}
out = append(out, r)
}
return out, rows.Err()
}
// Sync fetches every current deadline from source and reconciles the
// Outlook calendar to match:
// - unseen deadline_id -> a new Outlook event is created (AC1).
// - known deadline_id, content changed since the last sync -> the
// existing Outlook event is updated (AC2).
// - known deadline_id, content unchanged -> no-op.
// - stored record whose deadline_id is no longer in source's listing ->
// the Outlook event is deleted and the tracking record removed (AC3).
func (s *Store) Sync(ctx context.Context, ref Ref, source Source, target Target) (Result, error) {
deadlines, err := source.ListDeadlines(ctx)
if err != nil {
return Result{}, fmt.Errorf("m365calsync: sync: list deadlines: %w", err)
}
var res Result
seen := make(map[string]bool, len(deadlines))
for _, d := range deadlines {
seen[d.DeadlineID] = true
outcome, err := s.syncOne(ctx, ref, d, target)
if err != nil {
return res, fmt.Errorf("m365calsync: sync %s: %w", d.DeadlineID, err)
}
switch outcome {
case outcomeCreated:
res.Created++
case outcomeUpdated:
res.Updated++
}
}
stored, err := s.ListByRef(ctx, ref)
if err != nil {
return res, fmt.Errorf("m365calsync: sync: list stored: %w", err)
}
for _, rec := range stored {
if seen[rec.DeadlineID] {
continue
}
if err := s.deleteOne(ctx, ref, rec, target); err != nil {
return res, fmt.Errorf("m365calsync: sync: delete %s: %w", rec.DeadlineID, err)
}
res.Deleted++
}
return res, nil
}
type syncOutcome int
const (
outcomeNoop syncOutcome = iota
outcomeCreated
outcomeUpdated
)
func (s *Store) syncOne(ctx context.Context, ref Ref, d Deadline, target Target) (syncOutcome, error) {
hash := d.contentHash()
event := CalendarEvent{Subject: d.Title, Body: d.Description, Start: d.DueAt, End: d.DueAt}
existing, err := s.Get(ctx, ref, d.DeadlineID)
if errors.Is(err, ErrNotFound) {
externalEventID, createErr := target.CreateEvent(ctx, event)
if createErr != nil {
return outcomeNoop, fmt.Errorf("create event: %w", createErr)
}
_, insertErr := s.pool.Exec(ctx, `
INSERT INTO m365_calendar_events
(tenant_slug, connector_name, connector_instance_id, deadline_id,
external_event_id, title, due_at, content_hash)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
`, ref.TenantSlug, ref.ConnectorName, ref.ConnectorInstanceID, d.DeadlineID,
externalEventID, d.Title, d.DueAt, hash)
if insertErr != nil {
return outcomeNoop, fmt.Errorf("insert: %w", insertErr)
}
return outcomeCreated, nil
}
if err != nil {
return outcomeNoop, fmt.Errorf("lookup: %w", err)
}
if existing.ContentHash == hash {
return outcomeNoop, nil
}
if err := target.UpdateEvent(ctx, existing.ExternalEventID, event); err != nil {
return outcomeNoop, fmt.Errorf("update event: %w", err)
}
_, err = s.pool.Exec(ctx, `
UPDATE m365_calendar_events
SET title = $2, due_at = $3, content_hash = $4, updated_at = now()
WHERE id = $1
`, existing.ID, d.Title, d.DueAt, hash)
if err != nil {
return outcomeNoop, fmt.Errorf("update record: %w", err)
}
return outcomeUpdated, nil
}
func (s *Store) deleteOne(ctx context.Context, ref Ref, rec Record, target Target) error {
if err := target.DeleteEvent(ctx, rec.ExternalEventID); err != nil {
return fmt.Errorf("delete event: %w", err)
}
_, err := s.pool.Exec(ctx, `
DELETE FROM m365_calendar_events
WHERE tenant_slug = $1 AND connector_name = $2 AND connector_instance_id = $3 AND deadline_id = $4
`, ref.TenantSlug, ref.ConnectorName, ref.ConnectorInstanceID, rec.DeadlineID)
if err != nil {
return fmt.Errorf("delete record: %w", err)
}
return nil
}
@@ -0,0 +1,209 @@
package m365calsync
import (
"context"
"errors"
"fmt"
"os"
"path/filepath"
"testing"
"time"
"github.com/jackc/pgx/v5/pgxpool"
"gitea.perlbach24.de/scripte/nexarch/connect/internal/migrate"
"gitea.perlbach24.de/scripte/nexarch/connect/internal/testdb"
)
func newStore(t *testing.T) (*Store, *pgxpool.Pool) {
t.Helper()
dsn := os.Getenv("TEST_TENANT_DSN")
if dsn == "" {
t.Skip("TEST_TENANT_DSN nicht gesetzt, Integrationstest uebersprungen")
}
pool := testdb.Pool(t, dsn)
root, err := filepath.Abs("../../migrations")
if err != nil {
t.Fatalf("migrationsverzeichnis: %v", err)
}
migrations, err := migrate.Load(root)
if err != nil {
t.Fatalf("migrationen laden: %v", err)
}
if _, err := migrate.Up(context.Background(), pool, migrations); err != nil {
t.Fatalf("migrationen anwenden: %v", err)
}
return NewStore(pool), pool
}
var testRef = Ref{TenantSlug: "acme", ConnectorName: "m365", ConnectorInstanceID: "inst-1"}
// deadlineSource serves a fixed slice of deadlines, so a test can change
// what "NEXARCH currently has" between Sync calls.
type deadlineSource struct{ deadlines []Deadline }
func (s *deadlineSource) ListDeadlines(ctx context.Context) ([]Deadline, error) {
return s.deadlines, nil
}
// fakeTarget records every Create/Update/Delete call instead of talking to
// Graph, so Store.Sync's reconciliation logic can be tested independently
// of GraphTarget's HTTP mapping (which has its own test).
type fakeTarget struct {
nextID int
created []CalendarEvent
updated map[string]CalendarEvent
deleted map[string]bool
}
func newFakeTarget() *fakeTarget {
return &fakeTarget{updated: map[string]CalendarEvent{}, deleted: map[string]bool{}}
}
func (f *fakeTarget) CreateEvent(ctx context.Context, e CalendarEvent) (string, error) {
f.nextID++
f.created = append(f.created, e)
return fmt.Sprintf("evt-%d", f.nextID), nil
}
func (f *fakeTarget) UpdateEvent(ctx context.Context, externalEventID string, e CalendarEvent) error {
f.updated[externalEventID] = e
return nil
}
func (f *fakeTarget) DeleteEvent(ctx context.Context, externalEventID string) error {
f.deleted[externalEventID] = true
return nil
}
func mustTime(t *testing.T, s string) time.Time {
t.Helper()
ts, err := time.Parse(time.RFC3339, s)
if err != nil {
t.Fatalf("zeit parsen: %v", err)
}
return ts
}
// AC1 / Pruefung 1: eine neue Frist/Wiedervorlage erscheint nach Sync als
// Kalendereintrag in Outlook.
func TestSync_NewDeadline_CreatesCalendarEvent(t *testing.T) {
store, _ := newStore(t)
target := newFakeTarget()
src := &deadlineSource{deadlines: []Deadline{
{DeadlineID: "frist-1", Title: "Einspruchsfrist Mandant Muster", Description: "Frist laeuft ab",
DueAt: mustTime(t, "2026-10-15T09:00:00Z")},
}}
res, err := store.Sync(context.Background(), testRef, src, target)
if err != nil {
t.Fatalf("sync: %v", err)
}
if res.Created != 1 || res.Updated != 0 || res.Deleted != 0 {
t.Fatalf("erwartet 1 created, bekam %+v", res)
}
if len(target.created) != 1 || target.created[0].Subject != "Einspruchsfrist Mandant Muster" {
t.Fatalf("kalenderereignis nicht korrekt erzeugt: %+v", target.created)
}
rec, err := store.Get(context.Background(), testRef, "frist-1")
if err != nil {
t.Fatalf("get: %v", err)
}
if rec.ExternalEventID != "evt-1" || rec.Title != "Einspruchsfrist Mandant Muster" {
t.Fatalf("record nicht korrekt gespeichert: %+v", rec)
}
}
// AC2 / Pruefung 2: eine geaenderte Frist aktualisiert den bestehenden
// Kalendereintrag statt einen zweiten anzulegen.
func TestSync_ChangedDeadline_UpdatesExistingCalendarEvent(t *testing.T) {
store, _ := newStore(t)
target := newFakeTarget()
src := &deadlineSource{deadlines: []Deadline{
{DeadlineID: "frist-1", Title: "Klageerwiderung", Description: "",
DueAt: mustTime(t, "2026-10-15T09:00:00Z")},
}}
if _, err := store.Sync(context.Background(), testRef, src, target); err != nil {
t.Fatalf("erster sync: %v", err)
}
src.deadlines[0].DueAt = mustTime(t, "2026-10-20T09:00:00Z")
res, err := store.Sync(context.Background(), testRef, src, target)
if err != nil {
t.Fatalf("zweiter sync: %v", err)
}
if res.Created != 0 || res.Updated != 1 || res.Deleted != 0 {
t.Fatalf("erwartet 1 updated, bekam %+v", res)
}
if len(target.created) != 1 {
t.Fatalf("es darf kein zweites ereignis angelegt werden, erzeugt: %+v", target.created)
}
if _, ok := target.updated["evt-1"]; !ok {
t.Fatalf("bestehendes ereignis wurde nicht aktualisiert")
}
rec, err := store.Get(context.Background(), testRef, "frist-1")
if err != nil {
t.Fatalf("get: %v", err)
}
if !rec.DueAt.Equal(mustTime(t, "2026-10-20T09:00:00Z")) {
t.Fatalf("faelligkeit nicht uebernommen: %v", rec.DueAt)
}
}
// Gegenprobe zu AC2: unveraenderte Frist erzeugt keinen weiteren Push.
func TestSync_UnchangedDeadline_IsNoop(t *testing.T) {
store, _ := newStore(t)
target := newFakeTarget()
src := &deadlineSource{deadlines: []Deadline{
{DeadlineID: "frist-1", Title: "Wiedervorlage Vertragsablauf", Description: "",
DueAt: mustTime(t, "2026-11-01T08:00:00Z")},
}}
if _, err := store.Sync(context.Background(), testRef, src, target); err != nil {
t.Fatalf("erster sync: %v", err)
}
res, err := store.Sync(context.Background(), testRef, src, target)
if err != nil {
t.Fatalf("zweiter sync: %v", err)
}
if res.Created != 0 || res.Updated != 0 || res.Deleted != 0 {
t.Fatalf("erwartet keine aenderung, bekam %+v", res)
}
if len(target.updated) != 0 {
t.Fatalf("unveraenderte frist darf kein update ausloesen")
}
}
// AC3 / Pruefung 3: eine geloeschte Frist entfernt den Kalendereintrag
// vollstaendig.
func TestSync_DeletedDeadline_RemovesCalendarEvent(t *testing.T) {
store, _ := newStore(t)
target := newFakeTarget()
src := &deadlineSource{deadlines: []Deadline{
{DeadlineID: "frist-1", Title: "Termin geloescht", Description: "",
DueAt: mustTime(t, "2026-11-05T10:00:00Z")},
}}
if _, err := store.Sync(context.Background(), testRef, src, target); err != nil {
t.Fatalf("erster sync: %v", err)
}
src.deadlines = nil
res, err := store.Sync(context.Background(), testRef, src, target)
if err != nil {
t.Fatalf("zweiter sync: %v", err)
}
if res.Deleted != 1 {
t.Fatalf("erwartet 1 deleted, bekam %+v", res)
}
if !target.deleted["evt-1"] {
t.Fatalf("kalenderereignis wurde nicht geloescht")
}
if _, err := store.Get(context.Background(), testRef, "frist-1"); !errors.Is(err, ErrNotFound) {
t.Fatalf("record haette entfernt werden muessen, get() = %v", err)
}
}
@@ -143,25 +143,44 @@ func (c *Client) Read(ctx context.Context, secret credential.Secret, path string
return c.get(ctx, secret, path)
}
// Send performs a write call (POST/PATCH/DELETE) against a Graph path
// with the given JSON body, returning the raw response body. Generic
// write primitive alongside Read, used by M365-03's calendar-event push
// (M365-02 only needed reads).
func (c *Client) Send(ctx context.Context, secret credential.Secret, method, path string, body []byte) ([]byte, error) {
return c.send(ctx, secret, method, path, body)
}
func (c *Client) get(ctx context.Context, secret credential.Secret, path string) ([]byte, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.cfg.graphURL()+path, nil)
return c.send(ctx, secret, http.MethodGet, path, nil)
}
func (c *Client) send(ctx context.Context, secret credential.Secret, method, path string, body []byte) ([]byte, error) {
var bodyReader io.Reader
if body != nil {
bodyReader = strings.NewReader(string(body))
}
req, err := http.NewRequestWithContext(ctx, method, c.cfg.graphURL()+path, bodyReader)
if err != nil {
return nil, fmt.Errorf("%w: %w", ErrConnectionFailed, err)
}
req.Header.Set("Authorization", "Bearer "+secret.AccessToken)
if body != nil {
req.Header.Set("Content-Type", "application/json")
}
resp, err := c.http.Do(req)
if err != nil {
return nil, fmt.Errorf("%w: %w", ErrConnectionFailed, err)
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
respBody, _ := io.ReadAll(resp.Body)
if resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusForbidden {
return nil, fmt.Errorf("%w: status %d: %s", ErrPermissionExpired, resp.StatusCode, string(body))
return nil, fmt.Errorf("%w: status %d: %s", ErrPermissionExpired, resp.StatusCode, string(respBody))
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return nil, fmt.Errorf("%w: status %d: %s", ErrConnectionFailed, resp.StatusCode, string(body))
return nil, fmt.Errorf("%w: status %d: %s", ErrConnectionFailed, resp.StatusCode, string(respBody))
}
return body, nil
return respBody, nil
}
@@ -0,0 +1 @@
DROP TABLE IF EXISTS m365_calendar_events;
@@ -0,0 +1,23 @@
-- M365-03: Kopplung von NEXARCH-Fristen/Wiedervorlagen mit dem
-- Outlook-Kalender. Haelt je Frist/Wiedervorlage den zugehoerigen
-- Graph-Kalenderereignis-Id sowie einen Inhalts-Hash, um eine Aenderung
-- gegenueber dem zuletzt gepushten Stand zu erkennen (AC2) -- analog zum
-- Hash-Vergleich in m365_doc_records (M365-02), hier aber push- statt
-- pull-gerichtet: NEXARCH ist die fuehrende Seite, Outlook folgt.
CREATE TABLE m365_calendar_events (
id BIGSERIAL PRIMARY KEY,
tenant_slug TEXT NOT NULL,
connector_name TEXT NOT NULL,
connector_instance_id TEXT NOT NULL,
deadline_id TEXT NOT NULL,
external_event_id TEXT NOT NULL,
title TEXT NOT NULL,
due_at TIMESTAMPTZ NOT NULL,
content_hash TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (tenant_slug, connector_name, connector_instance_id, deadline_id)
);
CREATE INDEX m365_calendar_events_ref_idx
ON m365_calendar_events (tenant_slug, connector_name, connector_instance_id);
@@ -22,6 +22,7 @@ SECURITY_PACKAGES=(
"internal/fieldmapping"
"internal/masterdatasync"
"internal/m365docsync"
"internal/m365calsync"
"internal/syncjob"
"internal/statusapi"
)