# Sync SE-Ranking (enrichissement des kw sélectionnés) — Implementation Plan

> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.

**Goal:** Bouton « Sync SE-Ranking » sur la sélection du réservoir : enrichit les mots-clés sélectionnés (volume/KD/CPC/competition/intents/trend 12 mois) via l'API bulk SE-Ranking, clé plateforme.

**Architecture:** Même split que GSC — fetch HTTP en TS (server action), normalisation en Rust (wasm `import-core`), upsert Drizzle en TS. Un limiteur de débit process-wide (5 RPS) protège la clé partagée (10 RPS max). Merge « enrichment-safe » : jamais d'INSERT, jamais de réécriture d'identité du kw.

**Tech Stack:** Rust (serde_json, wasm-bindgen), Next.js 16 Server Actions, Drizzle/Postgres, bun test.

**Spec:** `docs/superpowers/specs/2026-07-14-seranking-enrichment-design.md` — la lire avant toute tâche.

## Global Constraints

- Branche : `feature/seranking-enrichment` (déjà créée). Conventional Commits. JAMAIS de merge main/prod.
- API : `POST https://api.seranking.com/v1/keywords/export?source=<db>` · header `Authorization: Token <clé>` · body `{"keywords": [...]}` (1–5000) · réponse : tableau `{is_data_found, keyword, volume, cpc, competition, difficulty, intents (lettres I/C/T/N/L), history_trend ("YYYY-MM-DD"→volume)}` · erreurs 400 (« Insufficient funds », source invalide), 403, 429, 5xx · **10 RPS max par clé** → limiteur à 5 RPS, requêtes séquentielles.
- Codes base régionale = alpha-2 **minuscule**, gotcha **GB→uk**.
- Wasm : après toute modif du crate → `bun run build:wasm && bun install` + restart dev (pas de HMR). Le build wasm régénère `src/lib/backlink/import-core/` (gitignoré — ne PAS le committer).
- Tests : DI plutôt que `mock.module` (fuite globale connue sous bun). Gate verte = `bun run test:all`.
- DB de test : les tests DB tournent via le script `test` (DATABASE_URL forcé sur `seo_insight_test`).
- Lint : biome (via le hook habituel) ; pas de réécriture de fichiers non touchés.

---

### Task 1: Rust — `parse_seranking_api` dans import-core

**Files:**
- Modify: `crates/import-core/src/keyword.rs` (ajout en fin de fichier)
- Modify: `crates/import-core/src/lib.rs` (export wasm)

**Interfaces:**
- Produces (consommé par Task 5 via wasm) :
  `parse_seranking_api(json: &str) -> Vec<SerankingApiRow>` exporté wasm en
  `parse_seranking_api(json: string): SerankingApiRow[]` où
  `SerankingApiRow = { term: string; found: boolean; metrics: KwMetrics | null; intent: string[]; trend: TrendPoint[] }`
  (camelCase serde ; `KwMetrics` = `{volume, kd, cpc, growthPct, competitiveDensity, numberOfResults}` nullable ; `TrendPoint` = `{month: "YYYY-MM", volume: number}`).

- [ ] **Step 1: Écrire les tests Rust (échouent)**

À la fin de `crates/import-core/src/keyword.rs` :

```rust
#[cfg(test)]
mod seranking_api_tests {
    use super::*;

    #[test]
    fn parses_api_response() {
        let json = r#"[{
            "is_data_found": true,
            "keyword": "Courtier Electricité",
            "volume": 260,
            "cpc": 3.05,
            "competition": 0.48,
            "difficulty": 29,
            "intents": ["L", "C"],
            "history_trend": {"2025-08-01": 200, "2026-07-01": 300, "2025-09-01": 250}
        }]"#;
        let rows = parse_seranking_api(json);
        assert_eq!(rows.len(), 1);
        let r = &rows[0];
        assert_eq!(r.term, "Courtier Electricité");
        assert!(r.found);
        let m = r.metrics.as_ref().unwrap();
        assert_eq!(m.volume, Some(260));
        assert_eq!(m.kd, Some(29.0));
        assert_eq!(m.cpc, Some(3.05));
        assert_eq!(m.competitive_density, Some(0.48));
        assert_eq!(m.number_of_results, None);
        // growth: first=200 (2025-08), last=300 (2026-07) → +50%
        assert_eq!(m.growth_pct, Some(50.0));
        assert_eq!(r.intent, vec!["local", "commercial"]);
        // BTreeMap: clés triées chronologiquement, mois "YYYY-MM"
        assert_eq!(r.trend, vec![
            TrendPoint { month: "2025-08".into(), volume: 200.0 },
            TrendPoint { month: "2025-09".into(), volume: 250.0 },
            TrendPoint { month: "2026-07".into(), volume: 300.0 },
        ]);
    }

    #[test]
    fn no_data_keyword_is_kept_with_found_false() {
        let json = r#"[{"is_data_found": false, "keyword": "xyzzy"}]"#;
        let rows = parse_seranking_api(json);
        assert_eq!(rows.len(), 1);
        assert!(!rows[0].found);
        assert_eq!(rows[0].term, "xyzzy");
        assert!(rows[0].metrics.is_none());
        assert!(rows[0].trend.is_empty());
    }

    #[test]
    fn growth_none_when_first_zero_or_single_point() {
        let json = r#"[
          {"is_data_found": true, "keyword": "a", "volume": 10,
           "history_trend": {"2025-08-01": 0, "2026-07-01": 300}},
          {"is_data_found": true, "keyword": "b", "volume": 10,
           "history_trend": {"2026-07-01": 300}},
          {"is_data_found": true, "keyword": "c", "volume": 10}
        ]"#;
        let rows = parse_seranking_api(json);
        assert_eq!(rows[0].metrics.as_ref().unwrap().growth_pct, None);
        assert_eq!(rows[1].metrics.as_ref().unwrap().growth_pct, None);
        assert_eq!(rows[2].metrics.as_ref().unwrap().growth_pct, None);
        assert!(rows[2].trend.is_empty());
    }

    #[test]
    fn malformed_json_returns_empty() {
        assert!(parse_seranking_api("{not json").is_empty());
        // Un objet au lieu d'un tableau → vide aussi (pas de panic).
        assert!(parse_seranking_api("{\"a\":1}").is_empty());
    }

    #[test]
    fn unknown_intent_letters_dropped() {
        let json = r#"[{"is_data_found": true, "keyword": "k", "volume": 1, "intents": ["I", "X", "T"]}]"#;
        let rows = parse_seranking_api(json);
        assert_eq!(rows[0].intent, vec!["informational", "transactional"]);
    }
}
```

- [ ] **Step 2: Vérifier l'échec**

Run: `cargo test -p import-core seranking_api`
Expected: FAIL — `parse_seranking_api` et `SerankingApiRow` n'existent pas.

- [ ] **Step 3: Implémenter**

Dans `crates/import-core/src/keyword.rs`, après `parse_keywords` (avant les mods de test) :

```rust
/// One keyword of a SE-Ranking bulk-export API response, normalized to the
/// canonical metric vocabulary. `found == false` mirrors `is_data_found` —
/// the term is kept so the caller can count/report "no data" keywords.
#[derive(Debug, Clone, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct SerankingApiRow {
    pub term: String,
    pub found: bool,
    pub metrics: Option<KwMetrics>,
    pub intent: Vec<String>,
    pub trend: Vec<TrendPoint>,
}

#[derive(Debug, serde::Deserialize)]
struct ApiKeyword {
    #[serde(default)]
    is_data_found: bool,
    #[serde(default)]
    keyword: String,
    #[serde(default)]
    volume: Option<i64>,
    #[serde(default)]
    cpc: Option<f64>,
    #[serde(default)]
    competition: Option<f64>,
    #[serde(default)]
    difficulty: Option<f64>,
    #[serde(default)]
    intents: Option<Vec<String>>,
    // BTreeMap: les clés "YYYY-MM-DD" triées lexicographiquement SONT triées
    // chronologiquement — l'ordre du trend en découle.
    #[serde(default)]
    history_trend: Option<std::collections::BTreeMap<String, f64>>,
}

/// Parse a SE-Ranking Keyword Research bulk-export API response (JSON array).
/// Malformed JSON → empty vec (the TS caller treats it as "0 rows", the HTTP
/// layer has already surfaced real API errors before reaching here).
pub fn parse_seranking_api(json: &str) -> Vec<SerankingApiRow> {
    let items: Vec<ApiKeyword> = match serde_json::from_str(json) {
        Ok(v) => v,
        Err(_) => return Vec::new(),
    };
    items
        .into_iter()
        .filter(|it| !it.keyword.trim().is_empty())
        .map(|it| {
            if !it.is_data_found {
                return SerankingApiRow {
                    term: it.keyword,
                    found: false,
                    metrics: None,
                    intent: Vec::new(),
                    trend: Vec::new(),
                };
            }
            let trend: Vec<TrendPoint> = it
                .history_trend
                .unwrap_or_default()
                .into_iter()
                .filter(|(date, _)| date.len() >= 7)
                .map(|(date, volume)| TrendPoint { month: date[..7].to_string(), volume })
                .collect();
            // Croissance sur la fenêtre d'historique : (dernier - premier) / premier.
            // None si < 2 points ou premier ≤ 0 (rien à comparer) — même esprit
            // que la règle Semrush "pas de volume → pas de trend trompeur".
            let growth_pct = match (trend.first(), trend.last()) {
                (Some(f), Some(l)) if trend.len() >= 2 && f.volume > 0.0 => {
                    Some(((l.volume - f.volume) / f.volume * 100.0).round())
                }
                _ => None,
            };
            let intent = it
                .intents
                .unwrap_or_default()
                .iter()
                .filter_map(|c| map_intent_code(c))
                .map(str::to_string)
                .collect();
            SerankingApiRow {
                term: it.keyword,
                found: true,
                metrics: Some(KwMetrics {
                    volume: it.volume,
                    kd: it.difficulty,
                    cpc: it.cpc,
                    growth_pct,
                    competitive_density: it.competition,
                    number_of_results: None,
                }),
                intent,
                trend,
            }
        })
        .collect()
}
```

- [ ] **Step 4: Tests Rust verts**

Run: `cargo test -p import-core`
Expected: PASS (nouveaux + existants).

- [ ] **Step 5: Export wasm**

Dans `crates/import-core/src/lib.rs`, après `parse_keywords_csv` :

```rust
/// SE-Ranking Keyword Research bulk API response → normalized rows
/// (camelCase JSON via serde). See keyword::parse_seranking_api.
#[wasm_bindgen]
pub fn parse_seranking_api(json: &str) -> Result<JsValue, JsValue> {
    let rows = keyword::parse_seranking_api(json);
    serde_wasm_bindgen::to_value(&rows).map_err(|e| JsValue::from_str(&e.to_string()))
}
```

- [ ] **Step 6: Rebuild wasm + vérifier l'export**

Run: `bun run build:wasm && bun install`
Expected: build OK ; `grep parse_seranking_api src/lib/backlink/import-core/import_core.d.ts` → `export function parse_seranking_api(json: string): any;`

- [ ] **Step 7: Commit**

```bash
git add crates/import-core/src/keyword.rs crates/import-core/src/lib.rs
git commit -m "feat(seranking): parse_seranking_api — normalisation Rust de la réponse bulk API"
```

---

### Task 2: TS — flag env + mapping pays→base régionale

**Files:**
- Modify: `src/env.ts`
- Create: `src/lib/seranking/country-db.ts`
- Test: `src/lib/seranking/country-db.test.ts`

**Interfaces:**
- Produces: `env.SERANKING_API_KEY: string | undefined`, `env.serankingEnabled: boolean` ;
  `serankingSource(country: string): string | null` (Task 5) — alpha-2 → code base SE-Ranking minuscule, `null` si non couvert.

- [ ] **Step 1: env.ts**

Dans le `schema` de `src/env.ts`, après le bloc Stripe :

```ts
  // SE-Ranking Data API — clé PLATEFORME (les crédits sont à notre charge).
  // Unset = feature « Sync SE-Ranking » masquée.
  SERANKING_API_KEY: z.string().optional(),
```

Dans l'objet `export const env`, après `analyticsEnabled` :

```ts
  /** Sync SE-Ranking (enrichissement bulk) — needs the platform API key. */
  serankingEnabled: Boolean(parsed.SERANKING_API_KEY),
```

- [ ] **Step 2: Test country-db (échoue)**

`src/lib/seranking/country-db.test.ts` :

```ts
// src/lib/seranking/country-db.test.ts
import { describe, expect, test } from "bun:test";
import { serankingSource } from "./country-db";

describe("serankingSource", () => {
  test("maps alpha-2 to lowercase db code", () => {
    expect(serankingSource("US")).toBe("us");
    expect(serankingSource("fr")).toBe("fr");
    expect(serankingSource("TH")).toBe("th");
  });
  test("GB maps to uk (SE-Ranking gotcha)", () => {
    expect(serankingSource("GB")).toBe("uk");
    expect(serankingSource("gb")).toBe("uk");
    // uk direct passe aussi (déjà le code SE-Ranking)
    expect(serankingSource("UK")).toBe("uk");
  });
  test("uncovered or garbage countries → null", () => {
    expect(serankingSource("XK")).toBeNull(); // Kosovo — pas de base SE-Ranking
    expect(serankingSource("")).toBeNull();
    expect(serankingSource("USA")).toBeNull();
  });
});
```

Run: `bun test src/lib/seranking/country-db.test.ts` → FAIL (module absent).

- [ ] **Step 3: Implémenter country-db.ts**

```ts
// src/lib/seranking/country-db.ts — mapping pays cible (alpha-2) → code base
// régionale SE-Ranking (paramètre `source` de la Data API).
// Liste officielle : https://seranking.com/api/data/reference#regional-database-codes
// ⚠️ Gotcha SE-Ranking : le Royaume-Uni est `uk`, PAS `gb` (alpha-2 standard).

const SERANKING_DBS = new Set([
  "af", "al", "dz", "as", "ao", "ai", "ag", "ar", "am", "aw", "au", "at",
  "az", "bs", "bh", "bd", "bb", "by", "be", "bz", "bj", "bt", "bo", "ba",
  "bw", "br", "vg", "bn", "bg", "bf", "bi", "cv", "kh", "cm", "ca", "ky",
  "cf", "td", "cl", "cn", "co", "cd", "cg", "ck", "cr", "ci", "hr", "cu",
  "cy", "cz", "dk", "dj", "dm", "do", "ec", "eg", "sv", "gq", "ee", "et",
  "fo", "fj", "fi", "fr", "gf", "pf", "ga", "gm", "ge", "de", "gh", "gi",
  "gr", "gl", "gd", "gp", "gu", "gt", "gg", "gn", "gy", "ht", "hn", "hk",
  "hu", "is", "in", "id", "iq", "ie", "im", "il", "it", "jm", "jp", "je",
  "jo", "kz", "ke", "ki", "kw", "kg", "la", "lv", "lb", "ls", "ly", "li",
  "lt", "lu", "mg", "mw", "my", "mv", "ml", "mt", "mq", "mr", "mu", "yt",
  "mx", "fm", "md", "mc", "mn", "me", "ms", "ma", "mz", "mm", "na", "nr",
  "np", "nl", "nc", "nz", "ni", "ne", "ng", "nu", "mk", "no", "om", "pk",
  "pa", "pg", "py", "pe", "ph", "pn", "pl", "pt", "pr", "qa", "re", "ro",
  "ru", "rw", "sh", "kn", "lc", "vc", "ws", "sm", "st", "sa", "sn", "rs",
  "sc", "sl", "sg", "sk", "si", "sb", "so", "za", "kr", "es", "lk", "ps",
  "sr", "se", "ch", "tw", "tj", "tz", "th", "tl", "tg", "tk", "to", "tt",
  "tn", "tr", "tm", "vi", "ug", "ua", "ae", "uk", "us", "uy", "uz", "vu",
  "ve", "vn", "ye", "zm", "zw",
]);

/** Pays cible (alpha-2, casse libre) → code base régionale SE-Ranking, ou
 *  null si SE-Ranking n'a pas de base pour ce pays (groupe à sauter). */
export function serankingSource(country: string): string | null {
  const lower = country.trim().toLowerCase();
  const code = lower === "gb" ? "uk" : lower;
  return SERANKING_DBS.has(code) ? code : null;
}
```

- [ ] **Step 4: Tests verts**

Run: `bun test src/lib/seranking/country-db.test.ts` → PASS.
Run: `bunx tsc --noEmit` → pas d'erreur.

- [ ] **Step 5: Commit**

```bash
git add src/env.ts src/lib/seranking/country-db.ts src/lib/seranking/country-db.test.ts
git commit -m "feat(seranking): flag env serankingEnabled + mapping pays→base régionale (GB→uk)"
```

---

### Task 3: TS — limiteur de débit process-wide (5 RPS)

**Files:**
- Create: `src/lib/seranking/rate-limit.ts`
- Test: `src/lib/seranking/rate-limit.test.ts`

**Interfaces:**
- Produces: `createRateLimiter(opts): { schedule<T>(fn: () => Promise<T>): Promise<T> }` (testable, horloge injectée) et le singleton `serankingLimiter` (200 ms d'espacement = 5 RPS), consommé par Task 4.

- [ ] **Step 1: Test (échoue)**

`src/lib/seranking/rate-limit.test.ts` :

```ts
// src/lib/seranking/rate-limit.test.ts — horloge virtuelle injectée : aucun
// vrai timer, le test est instantané et déterministe.
import { describe, expect, test } from "bun:test";
import { createRateLimiter } from "./rate-limit";

function virtualClock() {
  let t = 0;
  return {
    now: () => t,
    sleep: (ms: number) => {
      t += ms;
      return Promise.resolve();
    },
    time: () => t,
  };
}

describe("createRateLimiter", () => {
  test("spaces calls by minIntervalMs", async () => {
    const clock = virtualClock();
    const limiter = createRateLimiter({
      minIntervalMs: 200,
      now: clock.now,
      sleep: clock.sleep,
    });
    const starts: number[] = [];
    await Promise.all([
      limiter.schedule(async () => starts.push(clock.time())),
      limiter.schedule(async () => starts.push(clock.time())),
      limiter.schedule(async () => starts.push(clock.time())),
    ]);
    expect(starts).toEqual([0, 200, 400]);
  });

  test("serializes: a call never starts before the previous one finished", async () => {
    const clock = virtualClock();
    const limiter = createRateLimiter({
      minIntervalMs: 200,
      now: clock.now,
      sleep: clock.sleep,
    });
    const order: string[] = [];
    await Promise.all([
      limiter.schedule(async () => {
        order.push("a-start");
        await clock.sleep(500); // travail plus long que l'intervalle
        order.push("a-end");
      }),
      limiter.schedule(async () => {
        order.push("b-start");
      }),
    ]);
    expect(order).toEqual(["a-start", "a-end", "b-start"]);
  });

  test("a rejected task does not poison the queue", async () => {
    const clock = virtualClock();
    const limiter = createRateLimiter({
      minIntervalMs: 200,
      now: clock.now,
      sleep: clock.sleep,
    });
    await expect(
      limiter.schedule(async () => {
        throw new Error("boom");
      }),
    ).rejects.toThrow("boom");
    await expect(limiter.schedule(async () => 42)).resolves.toBe(42);
  });

  test("returns the task result", async () => {
    const limiter = createRateLimiter({ minIntervalMs: 0 });
    expect(await limiter.schedule(async () => "ok")).toBe("ok");
  });
});
```

Run: `bun test src/lib/seranking/rate-limit.test.ts` → FAIL.

- [ ] **Step 2: Implémenter**

```ts
// src/lib/seranking/rate-limit.ts — file d'attente process-wide devant l'API
// SE-Ranking. La clé plateforme est PARTAGÉE par toutes les orgs : N server
// actions concurrentes doivent se partager le budget (10 RPS par clé côté
// SE-Ranking) — plafond prudent à 5 RPS, marge pour les slices futures.
// Prod = process unique (systemd) ; multi-instance ⇒ limiteur à déplacer
// côté broker (hors périmètre, noté dans la spec).

export interface RateLimiter {
  /** Exécute `fn` dès qu'un slot est libre. Les appels sont SÉRIALISÉS (un
   *  seul en vol) et espacés d'au moins `minIntervalMs` entre deux départs. */
  schedule<T>(fn: () => Promise<T>): Promise<T>;
}

export function createRateLimiter({
  minIntervalMs,
  now = Date.now,
  sleep = (ms: number) => new Promise<void>((r) => setTimeout(r, ms)),
}: {
  minIntervalMs: number;
  now?: () => number;
  sleep?: (ms: number) => Promise<void>;
}): RateLimiter {
  let tail: Promise<unknown> = Promise.resolve();
  let lastStart = Number.NEGATIVE_INFINITY;
  return {
    schedule<T>(fn: () => Promise<T>): Promise<T> {
      const run = tail.then(async () => {
        const wait = lastStart + minIntervalMs - now();
        if (wait > 0) await sleep(wait);
        lastStart = now();
        return fn();
      });
      // La chaîne ne doit jamais rester rejetée, sinon tout appel suivant
      // hériterait de l'erreur — l'appelant, lui, reçoit `run` tel quel.
      tail = run.catch(() => {});
      return run;
    },
  };
}

/** Singleton process-wide — TOUT appel à l'API SE-Ranking passe par lui. */
export const serankingLimiter = createRateLimiter({ minIntervalMs: 200 });
```

- [ ] **Step 3: Tests verts**

Run: `bun test src/lib/seranking/rate-limit.test.ts` → PASS.

- [ ] **Step 4: Commit**

```bash
git add src/lib/seranking/rate-limit.ts src/lib/seranking/rate-limit.test.ts
git commit -m "feat(seranking): limiteur de débit process-wide (5 RPS, clé partagée)"
```

---

### Task 4: TS — client API (erreurs typées, retry, timeout)

**Files:**
- Create: `src/lib/seranking/client.ts`
- Test: `src/lib/seranking/client.test.ts`

**Interfaces:**
- Consumes: `serankingLimiter`/`RateLimiter` (Task 3), `env.SERANKING_API_KEY` (Task 2).
- Produces (consommé par Task 5) :
  ```ts
  export class SerankingError extends Error { kind: SerankingErrorKind }
  export type SerankingErrorKind =
    | "insufficient-credits" | "rate-limited" | "invalid-source"
    | "auth" | "http" | "network";
  // Résout en BODY BRUT (string JSON) — la validation/normalisation est
  // faite par le wasm Rust, pas ici.
  export async function bulkKeywordMetrics(opts: {
    source: string;
    keywords: string[];
    fetchImpl?: typeof fetch;   // DI tests
    limiter?: RateLimiter;      // DI tests
    sleep?: (ms: number) => Promise<void>; // DI tests (backoff retry)
  }): Promise<string>
  ```

- [ ] **Step 1: Tests (échouent)**

`src/lib/seranking/client.test.ts` :

```ts
// src/lib/seranking/client.test.ts — fetch injecté (DI, pas de mock.module).
import { describe, expect, test } from "bun:test";
import { bulkKeywordMetrics, SerankingError } from "./client";
import { createRateLimiter } from "./rate-limit";

const noWait = createRateLimiter({ minIntervalMs: 0 });
const noSleep = () => Promise.resolve();

function fakeFetch(
  responses: Array<{ status: number; body: string }>,
): { fetch: typeof fetch; calls: Array<{ url: string; init: RequestInit }> } {
  const calls: Array<{ url: string; init: RequestInit }> = [];
  let i = 0;
  const impl = (async (url: RequestInfo | URL, init?: RequestInit) => {
    calls.push({ url: String(url), init: init ?? {} });
    const r = responses[Math.min(i, responses.length - 1)];
    i++;
    return new Response(r.body, { status: r.status });
  }) as typeof fetch;
  return { fetch: impl, calls };
}

describe("bulkKeywordMetrics", () => {
  test("POSTs keywords to the right URL and returns the raw body", async () => {
    const { fetch, calls } = fakeFetch([{ status: 200, body: `[{"keyword":"a"}]` }]);
    const body = await bulkKeywordMetrics({
      source: "fr",
      keywords: ["a", "b"],
      fetchImpl: fetch,
      limiter: noWait,
      sleep: noSleep,
    });
    expect(body).toBe(`[{"keyword":"a"}]`);
    expect(calls).toHaveLength(1);
    expect(calls[0].url).toBe(
      "https://api.seranking.com/v1/keywords/export?source=fr",
    );
    expect(calls[0].init.method).toBe("POST");
    const headers = calls[0].init.headers as Record<string, string>;
    expect(headers.Authorization).toMatch(/^Token /);
    expect(JSON.parse(String(calls[0].init.body))).toEqual({ keywords: ["a", "b"] });
  });

  test("400 insufficient funds → insufficient-credits, no retry", async () => {
    const { fetch, calls } = fakeFetch([
      { status: 400, body: `{"message":"Insufficient funds - No credits left"}` },
    ]);
    const err = await bulkKeywordMetrics({
      source: "fr", keywords: ["a"], fetchImpl: fetch, limiter: noWait, sleep: noSleep,
    }).catch((e) => e);
    expect(err).toBeInstanceOf(SerankingError);
    expect((err as SerankingError).kind).toBe("insufficient-credits");
    expect(calls).toHaveLength(1);
  });

  test("400 invalid source → invalid-source", async () => {
    const { fetch } = fakeFetch([{ status: 400, body: `{"message":"Invalid source"}` }]);
    const err = await bulkKeywordMetrics({
      source: "zz", keywords: ["a"], fetchImpl: fetch, limiter: noWait, sleep: noSleep,
    }).catch((e) => e);
    expect((err as SerankingError).kind).toBe("invalid-source");
  });

  test("403 → auth", async () => {
    const { fetch } = fakeFetch([{ status: 403, body: `{"message":"License expired"}` }]);
    const err = await bulkKeywordMetrics({
      source: "fr", keywords: ["a"], fetchImpl: fetch, limiter: noWait, sleep: noSleep,
    }).catch((e) => e);
    expect((err as SerankingError).kind).toBe("auth");
  });

  test("429 retried once then succeeds", async () => {
    const { fetch, calls } = fakeFetch([
      { status: 429, body: `{"message":"Too many requests"}` },
      { status: 200, body: `[]` },
    ]);
    const body = await bulkKeywordMetrics({
      source: "fr", keywords: ["a"], fetchImpl: fetch, limiter: noWait, sleep: noSleep,
    });
    expect(body).toBe(`[]`);
    expect(calls).toHaveLength(2);
  });

  test("429 twice → rate-limited error", async () => {
    const { fetch, calls } = fakeFetch([
      { status: 429, body: `{}` },
      { status: 429, body: `{}` },
    ]);
    const err = await bulkKeywordMetrics({
      source: "fr", keywords: ["a"], fetchImpl: fetch, limiter: noWait, sleep: noSleep,
    }).catch((e) => e);
    expect((err as SerankingError).kind).toBe("rate-limited");
    expect(calls).toHaveLength(2);
  });

  test("500 retried once; second 500 → http error", async () => {
    const { fetch, calls } = fakeFetch([
      { status: 500, body: `` },
      { status: 503, body: `` },
    ]);
    const err = await bulkKeywordMetrics({
      source: "fr", keywords: ["a"], fetchImpl: fetch, limiter: noWait, sleep: noSleep,
    }).catch((e) => e);
    expect((err as SerankingError).kind).toBe("http");
    expect(calls).toHaveLength(2);
  });
});
```

Run: `bun run test src/lib/seranking/client.test.ts` → FAIL.
(⚠️ utiliser `bun run test` — le script npm — pas `bun test`, pour charger `.env.local` : `client.ts` importe `@/env` qui exige `DATABASE_URL`.)

- [ ] **Step 2: Implémenter**

```ts
// src/lib/seranking/client.ts — client minimal de la Data API SE-Ranking
// (bulk keyword export). CLÉ PLATEFORME : chaque appel passe par le limiteur
// process-wide (la clé est partagée par toutes les orgs — 10 RPS max).
import "server-only";
import { env } from "@/env";
import { type RateLimiter, serankingLimiter } from "./rate-limit";

const API_BASE = "https://api.seranking.com/v1";
const TIMEOUT_MS = 30_000;
const RETRY_BACKOFF_MS = 2_000;

export type SerankingErrorKind =
  | "insufficient-credits"
  | "rate-limited"
  | "invalid-source"
  | "auth"
  | "http"
  | "network";

export class SerankingError extends Error {
  readonly kind: SerankingErrorKind;
  constructor(kind: SerankingErrorKind, message: string) {
    super(message);
    this.name = "SerankingError";
    this.kind = kind;
  }
}

function classify(status: number, body: string): SerankingError {
  const lower = body.toLowerCase();
  if (status === 400 && (lower.includes("insufficient") || lower.includes("credit"))) {
    return new SerankingError("insufficient-credits", "Crédits SE-Ranking épuisés.");
  }
  if (status === 400 && lower.includes("source")) {
    return new SerankingError("invalid-source", `Base régionale refusée: ${body.slice(0, 200)}`);
  }
  if (status === 401 || status === 403) {
    return new SerankingError("auth", "Clé API SE-Ranking refusée ou licence expirée.");
  }
  if (status === 429) {
    return new SerankingError("rate-limited", "Rate limit SE-Ranking atteint.");
  }
  return new SerankingError("http", `SE-Ranking HTTP ${status}: ${body.slice(0, 200)}`);
}

/** Message utilisateur (FR) pour chaque famille d'erreur SE-Ranking. Vit ici
 *  (et pas dans actions.ts) : un module "use server" ne peut exporter que des
 *  async functions. */
export function serankingErrorMessage(e: SerankingError): string {
  switch (e.kind) {
    case "insufficient-credits":
      return "Crédits SE-Ranking épuisés — réessaie plus tard ou contacte l'équipe.";
    case "rate-limited":
      return "SE-Ranking limite le débit — réessaie dans quelques minutes.";
    case "auth":
      return "Connexion SE-Ranking refusée — configuration plateforme à vérifier.";
    case "invalid-source":
      return "Base régionale SE-Ranking refusée pour ce pays.";
    default:
      return "Échec du sync SE-Ranking — réessaie plus tard.";
  }
}

/** POST /keywords/export — métriques bulk (1–5000 kw, 100 crédits/requête).
 *  Résout en BODY BRUT (JSON string) : la normalisation est faite en Rust
 *  (wasm `parse_seranking_api`), pas ici. 1 retry sur 429/5xx. */
export async function bulkKeywordMetrics({
  source,
  keywords,
  fetchImpl = fetch,
  limiter = serankingLimiter,
  sleep = (ms: number) => new Promise<void>((r) => setTimeout(r, ms)),
}: {
  source: string;
  keywords: string[];
  fetchImpl?: typeof fetch;
  limiter?: RateLimiter;
  sleep?: (ms: number) => Promise<void>;
}): Promise<string> {
  const key = env.SERANKING_API_KEY;
  if (!key) throw new SerankingError("auth", "SERANKING_API_KEY non configurée.");

  const doFetch = () =>
    limiter.schedule(async () => {
      let res: Response;
      try {
        res = await fetchImpl(
          `${API_BASE}/keywords/export?source=${encodeURIComponent(source)}`,
          {
            method: "POST",
            headers: {
              Authorization: `Token ${key}`,
              "Content-Type": "application/json",
            },
            body: JSON.stringify({ keywords }),
            signal: AbortSignal.timeout(TIMEOUT_MS),
          },
        );
      } catch (e) {
        throw new SerankingError(
          "network",
          e instanceof Error ? e.message : "Échec réseau SE-Ranking.",
        );
      }
      const body = await res.text();
      if (!res.ok) throw classify(res.status, body);
      return body;
    });

  try {
    return await doFetch();
  } catch (e) {
    const retryable =
      e instanceof SerankingError &&
      (e.kind === "rate-limited" || e.kind === "http" || e.kind === "network");
    if (!retryable) throw e;
    await sleep(RETRY_BACKOFF_MS);
    return doFetch();
  }
}
```

- [ ] **Step 3: Tests verts**

Run: `bun run test src/lib/seranking/client.test.ts` → PASS.
Run: `bunx tsc --noEmit` → OK.

- [ ] **Step 4: Commit**

```bash
git add src/lib/seranking/client.ts src/lib/seranking/client.test.ts
git commit -m "feat(seranking): client bulk export — erreurs typées, timeout 30s, retry 429/5xx"
```

---

### Task 5: TS — orchestrateur de sync + merge enrichment-safe

**Files:**
- Create: `src/lib/seranking/sync.ts`
- Test: `src/lib/seranking/sync.test.ts` (logique pure) et section DB dans le même fichier (guarded `DB_TESTS`)

**Interfaces:**
- Consumes: `serankingSource` (Task 2), `bulkKeywordMetrics`/`SerankingError` (Task 4), wasm `parse_seranking_api` (Task 1), `selectionWhere`/`KeywordSelection` (`@/lib/keywords/list-query`), quotas (`lockOrgForQuota`, `assertWithinQuotaTx`, `getOrgQuotaLimits`, `assertWithinQuota` de `@/lib/billing/quotas`).
- Produces (consommé par Task 6) :
  ```ts
  export interface SerankingSyncResult {
    enriched: number;      // kw ayant reçu des métriques
    noData: number;        // is_data_found=false
    skippedCountries: string[]; // pays sans base SE-Ranking
    requests: number;      // requêtes API (×100 = crédits)
  }
  export async function syncSerankingForSelection(
    siteId: string,
    orgId: string,
    sel: KeywordSelection,
    deps?: { fetchBulk?: typeof bulkKeywordMetrics; parse?: (json: string) => SerankingApiRow[] },
  ): Promise<SerankingSyncResult>
  // + helpers purs exportés pour les tests :
  export interface SelectedKw { id: string; term: string; country: string; sources: string[] }
  export function groupByDb(rows: SelectedKw[]): {
    groups: Map<string, Map<string, string[]>>; // dbCode → lowerTerm → kw ids
    termOf: Map<string, string>;                // `${db} ${lowerTerm}` → terme original
    skippedCountries: string[];
  }
  export type SerankingApiRow = {
    term: string; found: boolean;
    metrics: { volume: number | null; kd: number | null; cpc: number | null;
      growthPct: number | null; competitiveDensity: number | null;
      numberOfResults: number | null } | null;
    intent: string[];
    trend: { month: string; volume: number }[];
  };
  ```

- [ ] **Step 1: Tests de la logique pure (échouent)**

`src/lib/seranking/sync.test.ts` :

```ts
// src/lib/seranking/sync.test.ts
import { describe, expect, test } from "bun:test";
import { groupByDb, type SelectedKw } from "./sync";

const kw = (id: string, term: string, country: string): SelectedKw => ({
  id, term, country, sources: [],
});

describe("groupByDb", () => {
  test("groups by SE-Ranking db code, dedupes by lowercased term", () => {
    const { groups, skippedCountries } = groupByDb([
      kw("1", "Plombier Paris", "FR"),
      kw("2", "plombier paris", "FR"), // même terme (casse) → même envoi
      kw("3", "plumber london", "GB"), // GB → base "uk"
      kw("4", "foo", "XK"),            // pas de base → sauté
    ]);
    expect([...groups.keys()].sort()).toEqual(["fr", "uk"]);
    expect(groups.get("fr")?.get("plombier paris")).toEqual(["1", "2"]);
    expect(groups.get("uk")?.get("plumber london")).toEqual(["3"]);
    expect(skippedCountries).toEqual(["XK"]);
  });

  test("keeps one original-cased term per group for the API payload", () => {
    const { termOf } = groupByDb([kw("1", "Plombier Paris", "FR")]);
    expect(termOf.get("fr plombier paris")).toBe("Plombier Paris");
  });

  test("empty input → empty groups, no skips", () => {
    const { groups, skippedCountries } = groupByDb([]);
    expect(groups.size).toBe(0);
    expect(skippedCountries).toEqual([]);
  });
});
```

Run: `bun run test src/lib/seranking/sync.test.ts` → FAIL.

- [ ] **Step 2: Implémenter sync.ts**

```ts
// src/lib/seranking/sync.ts — orchestrateur du « Sync SE-Ranking » : résout la
// sélection, groupe par base régionale, appelle l'API bulk (via le limiteur),
// normalise en Rust (wasm) et merge ENRICHMENT-SAFE : un enrichissement ne
// réécrit jamais l'identité d'un mot-clé (term/lang/country/status/theme
// intacts) et ne crée jamais de ligne — UPDATE only.
import "server-only";
import { inArray, sql } from "drizzle-orm";
import * as wasm from "import-core";
import {
  assertWithinQuota,
  assertWithinQuotaTx,
  getOrgQuotaLimits,
  lockOrgForQuota,
} from "@/lib/billing/quotas";
import { chunk } from "@/lib/db/chunk";
import { db } from "@/lib/db/client";
import { keywords } from "@/lib/db/domain-schema";
import {
  type KeywordSelection,
  selectionWhere,
} from "@/lib/keywords/list-query";
import { bulkKeywordMetrics } from "./client";
import { serankingSource } from "./country-db";

/** Cap aligné sur MAX_FILTER_EXPORT (actions.ts) — un sync ne traite jamais
 *  plus que ce que les exports/bulk actions acceptent. */
const MAX_SYNC = 20_000;
/** Limite API : 5000 kw par requête bulk. */
const API_CHUNK = 5_000;
/** Lignes UPDATE par transaction d'écriture. */
const WRITE_CHUNK = 500;

export interface SerankingSyncResult {
  enriched: number;
  noData: number;
  skippedCountries: string[];
  requests: number;
}

export interface SelectedKw {
  id: string;
  term: string;
  country: string;
  sources: string[];
}

export type SerankingApiRow = {
  term: string;
  found: boolean;
  metrics: {
    volume: number | null;
    kd: number | null;
    cpc: number | null;
    growthPct: number | null;
    competitiveDensity: number | null;
    numberOfResults: number | null;
  } | null;
  intent: string[];
  trend: { month: string; volume: number }[];
};

/** Groupe la sélection par base SE-Ranking, dédupe par terme lowercasé (un
 *  terme envoyé une fois peut porter plusieurs ids), collecte les pays sans
 *  base. Pur — testé sans DB. */
export function groupByDb(rows: SelectedKw[]): {
  groups: Map<string, Map<string, string[]>>;
  termOf: Map<string, string>;
  skippedCountries: string[];
} {
  const groups = new Map<string, Map<string, string[]>>();
  const termOf = new Map<string, string>();
  const skipped = new Set<string>();
  for (const r of rows) {
    const dbCode = serankingSource(r.country);
    if (!dbCode) {
      skipped.add(r.country.toUpperCase());
      continue;
    }
    const lower = r.term.trim().toLowerCase();
    if (!lower) continue;
    let group = groups.get(dbCode);
    if (!group) {
      group = new Map();
      groups.set(dbCode, group);
    }
    const ids = group.get(lower);
    if (ids) ids.push(r.id);
    else {
      group.set(lower, [r.id]);
      termOf.set(`${dbCode} ${lower}`, r.term.trim());
    }
  }
  return { groups, termOf, skippedCountries: [...skipped].sort() };
}

/** Applique un lot de résultats API aux mots-clés (par ids), enrichment-safe,
 *  dans UNE transaction verrouillée quota. Retourne le nb de kw enrichis. */
async function applyRows(
  orgId: string,
  updates: Array<{ ids: string[]; row: SerankingApiRow }>,
  today: string,
): Promise<number> {
  if (updates.length === 0) return 0;
  const limits = await getOrgQuotaLimits(orgId);
  let enriched = 0;
  for (const slice of chunk(updates, WRITE_CHUNK)) {
    await db.transaction(async (tx) => {
      await lockOrgForQuota(tx, orgId);
      // Delta réel : ids qui n'ont pas encore la source (kw×source quota).
      const allIds = slice.flatMap((u) => u.ids);
      const existing = await tx
        .select({ id: keywords.id, sources: keywords.sources })
        .from(keywords)
        .where(inArray(keywords.id, allIds));
      const delta = existing.filter((k) => !k.sources.includes("seranking")).length;
      if (delta > 0) {
        await assertWithinQuotaTx(tx, orgId, "keywords", limits.keywords, delta);
      }
      for (const { ids, row } of slice) {
        if (!row.metrics) continue;
        const metricsJson = JSON.stringify({
          seranking: { ...row.metrics, updatedAt: today },
        });
        const trendJson = JSON.stringify(row.trend);
        const trendBySourceJson = JSON.stringify({ seranking: row.trend });
        const intentArr = row.intent;
        const res = await tx
          .update(keywords)
          .set({
            metrics: sql`${keywords.metrics} || ${metricsJson}::jsonb`,
            sources: sql`(select coalesce(array_agg(distinct s), '{}') from unnest(${keywords.sources} || '{seranking}'::text[]) s)`,
            intent: sql`(select coalesce(array_agg(distinct i), '{}') from unnest(${keywords.intent} || ${sql.raw(`'{${intentArr.map((i) => `"${i}"`).join(",")}}'`)}::text[]) i)`,
            // primarySource bascule vers seranking UNIQUEMENT si la source
            // primaire actuelle ne porte aucun volume (cas GSC/manuel) —
            // même condition que hasNoMetric côté UI.
            primarySource: sql`CASE WHEN (${keywords.metrics} -> ${keywords.primarySource} ->> 'volume') IS NULL THEN 'seranking' ELSE ${keywords.primarySource} END`,
            // Spread conditionnel — ne PAS passer de clés `undefined` à .set().
            ...(row.trend.length
              ? {
                  trendBySource: sql`${keywords.trendBySource} || ${trendBySourceJson}::jsonb`,
                  // trend global (fallback sparkline) : rempli seulement s'il est vide.
                  trend: sql`CASE WHEN jsonb_array_length(${keywords.trend}) = 0 THEN ${trendJson}::jsonb ELSE ${keywords.trend} END`,
                }
              : {}),
          })
          .where(inArray(keywords.id, ids))
          .returning({ id: keywords.id });
        enriched += res.length;
      }
    });
  }
  return enriched;
}

/** Sync SE-Ranking d'une sélection du réservoir. Le pré-check quota tourne
 *  AVANT le premier appel API (ne pas dépenser de crédits pour échouer au
 *  moment d'écrire) ; la vérification autoritaire reste dans la transaction
 *  d'écriture (lockOrgForQuota + assertWithinQuotaTx). */
export async function syncSerankingForSelection(
  siteId: string,
  orgId: string,
  sel: KeywordSelection,
  deps?: {
    fetchBulk?: typeof bulkKeywordMetrics;
    parse?: (json: string) => SerankingApiRow[];
  },
): Promise<SerankingSyncResult> {
  const fetchBulk = deps?.fetchBulk ?? bulkKeywordMetrics;
  const parse =
    deps?.parse ??
    ((json: string) => wasm.parse_seranking_api(json) as SerankingApiRow[]);

  const rows: SelectedKw[] = await db
    .select({
      id: keywords.id,
      term: keywords.term,
      country: keywords.country,
      sources: keywords.sources,
    })
    .from(keywords)
    .where(selectionWhere(siteId, sel))
    .limit(MAX_SYNC);

  const { groups, termOf, skippedCountries } = groupByDb(rows);

  // Pré-check quota (advisory, hors transaction — voir doc assertWithinQuota).
  const projectedDelta = rows.filter((r) => !r.sources.includes("seranking")).length;
  if (projectedDelta > 0) {
    await assertWithinQuota(orgId, "keywords", projectedDelta);
  }

  const today = new Date().toISOString().slice(0, 10);
  let enriched = 0;
  let noData = 0;
  let requests = 0;

  for (const [dbCode, terms] of groups) {
    const lowerTerms = [...terms.keys()];
    for (const slice of chunk(lowerTerms, API_CHUNK)) {
      const payload = slice.map((t) => termOf.get(`${dbCode} ${t}`) ?? t);
      const raw = await fetchBulk({ source: dbCode, keywords: payload });
      requests++;
      const parsed = parse(raw);
      const updates: Array<{ ids: string[]; row: SerankingApiRow }> = [];
      for (const row of parsed) {
        const ids = terms.get(row.term.trim().toLowerCase());
        if (!ids) continue; // écho inconnu — ignoré
        if (!row.found || !row.metrics) {
          noData += ids.length;
          continue;
        }
        updates.push({ ids, row });
      }
      enriched += await applyRows(orgId, updates, today);
    }
  }

  // Observabilité ops : crédits consommés (100/requête bulk).
  console.info(
    `[seranking] org=${orgId} site=${siteId} requests=${requests} credits=${requests * 100} enriched=${enriched} noData=${noData} skipped=${skippedCountries.join(",") || "-"}`,
  );

  return { enriched, noData, skippedCountries, requests };
}
```

⚠️ Note implémentation : le fragment `intent` ci-dessus interpole un littéral
d'array Postgres construit depuis les intents wasm (vocabulaire fermé :
`informational|commercial|transactional|navigational|local`, garanti par
`map_intent_code` côté Rust). Si le reviewer préfère zéro `sql.raw`, variante
équivalente avec paramètre lié :
`sql`(select coalesce(array_agg(distinct i), '{}') from unnest(${keywords.intent} || ${intentArr}::text[]) i)``
— tester que drizzle lie bien `string[]` en `text[]` (il le fait via
postgres-js) ; adopter cette variante si elle passe le test DB.

- [ ] **Step 3: Tests purs verts**

Run: `bun run test src/lib/seranking/sync.test.ts` → PASS (groupByDb).

- [ ] **Step 4: Test DB du merge enrichment-safe (échoue puis passe)**

Ajouter à `src/lib/seranking/sync.test.ts` (même pattern de guard que les
tests DB existants — voir `src/lib/db/keywords-integrity.test.ts` pour le
modèle exact de setup/teardown et `assertTestDb`) :

```ts
import { afterAll, beforeAll } from "bun:test";

const DB = process.env.DB_TESTS === "1";

if (DB) {
  const { db } = await import("@/lib/db/client");
  const { keywords, sites } = await import("@/lib/db/domain-schema");
  const { organization } = await import("@/lib/db/auth-schema");
  const { syncSerankingForSelection } = await import("./sync");
  const { eq, inArray } = await import("drizzle-orm");

  const ORG = "org-srk-test";
  const SITE = "site-srk-test";
  const IDS = [`${SITE}-plombier-paris`, `${SITE}-gsc-only`, `${SITE}-no-data`];

  beforeAll(async () => {
    await db.insert(organization).values({
      id: ORG, name: "srk-test", slug: "srk-test", createdAt: new Date(),
    }).onConflictDoNothing();
    await db.insert(sites).values({
      id: SITE, orgId: ORG, name: "srk", plan: "starter", primaryLanguage: "fr",
    }).onConflictDoNothing();
    await db.insert(keywords).values([
      {
        id: IDS[0], siteId: SITE, term: "Plombier Paris", lang: "fr",
        country: "FR", status: "idea", intent: ["informational"],
        primarySource: "semrush", sources: ["semrush"],
        metrics: { semrush: { volume: 900, kd: 10, cpc: 1, growthPct: null, updatedAt: "2026-01-01" } },
        trend: [{ month: "2026-01", volume: 900 }],
      },
      {
        id: IDS[1], siteId: SITE, term: "gsc only", lang: "fr", country: "FR",
        status: "idea", intent: [], primarySource: "gsc", sources: ["gsc"],
        metrics: {}, trend: [],
      },
      {
        id: IDS[2], siteId: SITE, term: "no data", lang: "fr", country: "FR",
        status: "idea", intent: [], primarySource: "manual", sources: ["manual"],
        metrics: {}, trend: [],
      },
    ]);
  });

  afterAll(async () => {
    await db.delete(keywords).where(inArray(keywords.id, IDS));
    await db.delete(sites).where(eq(sites.id, SITE));
    await db.delete(organization).where(eq(organization.id, ORG));
  });

  const API_ROW = (term: string) => ({
    term, found: true,
    metrics: { volume: 260, kd: 29, cpc: 3.05, growthPct: 50, competitiveDensity: 0.48, numberOfResults: null },
    intent: ["local", "commercial"],
    trend: [{ month: "2026-06", volume: 240 }, { month: "2026-07", volume: 260 }],
  });

  test("enrichment-safe merge: metrics/sources/intent merged, identity untouched", async () => {
    const res = await syncSerankingForSelection(
      SITE, ORG, { mode: "ids", ids: IDS },
      {
        fetchBulk: async () => "unused",
        parse: () => [
          API_ROW("Plombier Paris"),
          API_ROW("gsc only"),
          { term: "no data", found: false, metrics: null, intent: [], trend: [] },
        ],
      },
    );
    expect(res.enriched).toBe(2);
    expect(res.noData).toBe(1);
    expect(res.requests).toBe(1);

    const [semrushKw] = await db.select().from(keywords).where(eq(keywords.id, IDS[0]));
    // identité intacte
    expect(semrushKw.term).toBe("Plombier Paris");
    expect(semrushKw.country).toBe("FR");
    expect(semrushKw.status).toBe("idea");
    // metrics mergées, pas écrasées
    expect(semrushKw.metrics.semrush?.volume).toBe(900);
    expect(semrushKw.metrics.seranking?.volume).toBe(260);
    // primarySource conservée (semrush portait un volume)
    expect(semrushKw.primarySource).toBe("semrush");
    // unions
    expect([...semrushKw.sources].sort()).toEqual(["semrush", "seranking"]);
    expect([...semrushKw.intent].sort()).toEqual(["commercial", "informational", "local"]);
    // trend global déjà rempli → intact ; par-source ajouté
    expect(semrushKw.trend).toEqual([{ month: "2026-01", volume: 900 }]);
    expect(semrushKw.trendBySource.seranking).toHaveLength(2);

    const [gscKw] = await db.select().from(keywords).where(eq(keywords.id, IDS[1]));
    // aucune métrique avant → primarySource bascule + trend global rempli
    expect(gscKw.primarySource).toBe("seranking");
    expect(gscKw.trend).toHaveLength(2);

    const [noDataKw] = await db.select().from(keywords).where(eq(keywords.id, IDS[2]));
    expect(noDataKw.metrics).toEqual({});
    expect(noDataKw.sources).toEqual(["manual"]);
  });

  test("re-sync is idempotent on quota (source already present)", async () => {
    const res = await syncSerankingForSelection(
      SITE, ORG, { mode: "ids", ids: [IDS[0]] },
      { fetchBulk: async () => "unused", parse: () => [API_ROW("Plombier Paris")] },
    );
    expect(res.enriched).toBe(1);
    const [kw] = await db.select().from(keywords).where(eq(keywords.id, IDS[0]));
    expect(kw.sources.filter((s) => s === "seranking")).toHaveLength(1);
  });
}
```

Run: `bun run test src/lib/seranking/sync.test.ts` → PASS (pur + DB).
(⚠️ vérifier les valeurs NOT NULL exactes de `sites` dans `domain-schema.ts`
au moment d'écrire le setup — ajuster les colonnes obligatoires si besoin.)

- [ ] **Step 5: Commit**

```bash
git add src/lib/seranking/sync.ts src/lib/seranking/sync.test.ts
git commit -m "feat(seranking): orchestrateur de sync — groupage par base, merge enrichment-safe, quota kw×source"
```

---

### Task 6: TS — server action `syncSerankingAction`

**Files:**
- Create: `src/lib/seranking/actions.ts`
- Test: `src/lib/seranking/actions.test.ts`

**Interfaces:**
- Consumes: `syncSerankingForSelection` (Task 5), `env.serankingEnabled` (Task 2), `assertSiteInActiveOrg` (`@/lib/sites/assert-site`), `KeywordSelection` (`@/lib/keywords/list-query`), `keywordOverviewTag` (`@/lib/keywords/queries`), `QuotaError`/`quotaPayload`/`QuotaExceeded` (`@/lib/billing/quotas`), `SerankingError` (Task 4).
- Produces (consommé par Task 7) :
  ```ts
  export type SyncSerankingResult =
    | ({ ok: true } & SerankingSyncResult)
    | { ok: false; error: string; quota?: QuotaExceeded };
  export async function syncSerankingAction(
    siteId: string,
    selection: KeywordSelection,
  ): Promise<SyncSerankingResult>
  ```

- [ ] **Step 1: Implémenter** (l'action est un adaptateur mince — la logique
  testable vit dans `sync.ts` ; on écrit le test sur le mapping d'erreurs via
  la fonction interne exportée)

```ts
// src/lib/seranking/actions.ts — server action du « Sync SE-Ranking » (réservoir).
"use server";
import { eq } from "drizzle-orm";
import { updateTag } from "next/cache";
import { env } from "@/env";
import {
  QuotaError,
  type QuotaExceeded,
  quotaPayload,
} from "@/lib/billing/quotas";
import { db } from "@/lib/db/client";
import { sites } from "@/lib/db/domain-schema";
import type { KeywordSelection } from "@/lib/keywords/list-query";
import { keywordOverviewTag } from "@/lib/keywords/queries";
import { assertSiteInActiveOrg } from "@/lib/sites/assert-site";
import { SerankingError, serankingErrorMessage } from "./client";
import {
  type SerankingSyncResult,
  syncSerankingForSelection,
} from "./sync";

export type SyncSerankingResult =
  | ({ ok: true } & SerankingSyncResult)
  | { ok: false; error: string; quota?: QuotaExceeded };

/** Enrichit les mots-clés sélectionnés via l'API bulk SE-Ranking. Même gate
 *  que les imports : tenancy + site partagé (assertSiteInActiveOrg) ; le
 *  quota keywords (kw×source) est vérifié dans sync.ts. */
export async function syncSerankingAction(
  siteId: string,
  selection: KeywordSelection,
): Promise<SyncSerankingResult> {
  if (!env.serankingEnabled) {
    return { ok: false, error: "SE-Ranking n'est pas configuré." };
  }
  await assertSiteInActiveOrg(siteId);
  if (selection.mode === "ids" && selection.ids.length === 0) {
    return { ok: false, error: "Sélection vide." };
  }
  const [site] = await db
    .select({ orgId: sites.orgId })
    .from(sites)
    .where(eq(sites.id, siteId));
  if (!site) return { ok: false, error: "Site introuvable." };
  try {
    const res = await syncSerankingForSelection(siteId, site.orgId, selection);
    updateTag(keywordOverviewTag(siteId));
    return { ok: true, ...res };
  } catch (e) {
    if (e instanceof QuotaError) {
      return { ok: false, error: e.message, quota: quotaPayload(e) };
    }
    if (e instanceof SerankingError) {
      return { ok: false, error: serankingErrorMessage(e) };
    }
    throw e;
  }
}
```

- [ ] **Step 2: Test du mapping d'erreurs**

Le mapping vit dans `client.ts` (module "use server" = exports async only) —
le test va donc avec le client :

`src/lib/seranking/actions.test.ts` :

```ts
// src/lib/seranking/actions.test.ts
import { describe, expect, test } from "bun:test";
import { SerankingError, serankingErrorMessage } from "./client";

describe("serankingErrorMessage", () => {
  test("maps every kind to a French user message", () => {
    expect(serankingErrorMessage(new SerankingError("insufficient-credits", "x")))
      .toContain("Crédits");
    expect(serankingErrorMessage(new SerankingError("rate-limited", "x")))
      .toContain("débit");
    expect(serankingErrorMessage(new SerankingError("auth", "x")))
      .toContain("refusée");
    expect(serankingErrorMessage(new SerankingError("invalid-source", "x")))
      .toContain("régionale");
    expect(serankingErrorMessage(new SerankingError("http", "x")))
      .toContain("Échec");
    expect(serankingErrorMessage(new SerankingError("network", "x")))
      .toContain("Échec");
  });
});
```

Run: `bun run test src/lib/seranking/actions.test.ts` → PASS.
Run: `bunx tsc --noEmit` → OK.

- [ ] **Step 3: Commit**

```bash
git add src/lib/seranking/actions.ts src/lib/seranking/actions.test.ts
git commit -m "feat(seranking): server action syncSerankingAction — gates, quota payload, messages FR"
```

---

### Task 7: UI — bouton « Sync SE-Ranking » dans la barre de sélection

**Files:**
- Modify: `src/components/keywords/bulk-actions-bar.tsx`
- Modify: `src/components/keywords/reservoir.tsx`
- Modify: `src/app/(app)/keywords/reservoir/page.tsx`

**Interfaces:**
- Consumes: `syncSerankingAction` (Task 6), `env.serankingEnabled` (Task 2), l'état `selection: KeywordSelection` existant de `reservoir.tsx` (ligne ~491).

- [ ] **Step 1: BulkActionsBar — props optionnelles + bouton**

Dans `src/components/keywords/bulk-actions-bar.tsx` :

1. Ajouter aux imports lucide existants : `RefreshCwIcon`.
2. Ajouter les props (destructuring + type) après `onSendToContent` :

```tsx
  onSendToContent: () => void;
  /** Sync SE-Ranking de la sélection (undefined = feature non configurée → bouton absent). */
  onSyncSeranking?: () => void;
  /** true pendant le sync — spinner + bouton désactivé. */
  syncingSeranking?: boolean;
  onDelete: () => void;
```

3. Ajouter le bouton juste APRÈS le DropdownMenu « Envoyer vers » (même style
   que les autres boutons de la barre — réutiliser la constante `BULK_BTN`
   du fichier) :

```tsx
      {onSyncSeranking ? (
        <Button
          variant="outline"
          className={BULK_BTN}
          disabled={syncingSeranking}
          onClick={onSyncSeranking}
        >
          <RefreshCwIcon
            className={
              syncingSeranking ? "size-3.5 animate-spin" : "size-3.5"
            }
            aria-hidden
          />
          Sync SE-Ranking
        </Button>
      ) : null}
```

(Vérifier le nom exact de la constante de classe des boutons — `BULK_BTN`
ligne ~257 — et si `Button` est déjà importé dans ce fichier.)

- [ ] **Step 2: reservoir.tsx — handler + wiring**

Dans `src/components/keywords/reservoir.tsx` :

1. Prop nouvelle (à côté de `analyticsEnabled`, lignes ~125 et ~133) :

```tsx
  serankingEnabled?: boolean;
```

2. Imports : `import { syncSerankingAction } from "@/lib/seranking/actions";`
   (+ `useTransition` et `toast` sont déjà importés — vérifier).

3. Handler à côté des autres handlers bulk (~ligne 585, après `deleteSelected`) :

```tsx
  const [serankingSyncing, startSerankingSync] = useTransition();
  function syncSeranking() {
    startSerankingSync(async () => {
      const res = await syncSerankingAction(activeSiteId, selection);
      if (!res.ok) {
        toast.error(res.error);
        return;
      }
      const parts = [`${res.enriched} enrichi(s)`];
      if (res.noData > 0) parts.push(`${res.noData} sans données SE-Ranking`);
      if (res.skippedCountries.length > 0)
        parts.push(`pays non couvert : ${res.skippedCountries.join(", ")}`);
      toast.success(`Sync SE-Ranking : ${parts.join(" · ")}`);
      clearSelectionState();
      refresh();
    });
  }
```

(Vérifier les noms exacts : `clearSelectionState` et `refresh` existent déjà
dans le composant — lignes ~224 et usages `onDone`.)

4. Passer à `<BulkActionsBar>` (~ligne 682) :

```tsx
          onSyncSeranking={serankingEnabled ? syncSeranking : undefined}
          syncingSeranking={serankingSyncing}
```

- [ ] **Step 3: page.tsx — flag serveur**

Dans `src/app/(app)/keywords/reservoir/page.tsx` (~ligne 92, à côté de
`analyticsEnabled`) :

```tsx
        serankingEnabled={env.serankingEnabled}
```

- [ ] **Step 4: Vérifier types + tests existants**

Run: `bunx tsc --noEmit` → OK.
Run: `bun run test src/components` → pas de régression (les tests composants
existants ne montent pas BulkActionsBar avec les nouvelles props — elles sont
optionnelles).

- [ ] **Step 5: Vérif visuelle dev**

Le dev server tourne (bun dev). Sur `http://localhost:3000/keywords/reservoir`
(compte dev habituel) : sélectionner 2–3 mots-clés → la barre sticky affiche
« Sync SE-Ranking » → cliquer → toast récap et métriques SE-Ranking visibles
(pastille source + colonnes volume/KD/CPC). Si la clé dev n'a pas de crédits,
vérifier au minimum le toast d'erreur propre « Crédits SE-Ranking épuisés… ».

- [ ] **Step 6: Commit**

```bash
git add src/components/keywords/bulk-actions-bar.tsx src/components/keywords/reservoir.tsx "src/app/(app)/keywords/reservoir/page.tsx"
git commit -m "feat(seranking): bouton Sync SE-Ranking sur la sélection du réservoir"
```

---

### Task 8: Gate finale + docs

**Files:**
- Modify: `docs/project/roadmap.md` (entrée ✅ + reste-à-faire)

- [ ] **Step 1: Gate complète**

Run: `bun run test:all`
Expected: 100 % vert. (Les tests Rust : `cargo test -p import-core` → vert aussi.)

- [ ] **Step 2: Roadmap**

Dans `docs/project/roadmap.md`, section des features livrées, ajouter (adapter
au format des entrées voisines) :

```md
- ✅ **Sync SE-Ranking (enrichissement)** — bouton sur la sélection du
  réservoir : métriques volume/KD/CPC/competition/intents + trend 12 mois via
  l'API bulk (clé plateforme, 100 crédits/requête, limiteur 5 RPS process-wide,
  merge enrichment-safe, quota kw×source). Spec :
  `docs/superpowers/specs/2026-07-14-seranking-enrichment-design.md`.
  Reste-à-faire (slices futures) : découverte (similar/related/questions/
  longtail), auto-enrich post-GSC, métering crédits par org, limiteur côté
  broker si multi-instance.
```

- [ ] **Step 3: Commit**

```bash
git add docs/project/roadmap.md
git commit -m "docs(roadmap): sync SE-Ranking (enrichissement) livré"
```

---

## Vérification finale (avant merge)

1. `bun run test:all` vert + `cargo test -p import-core` vert.
2. `bunx tsc --noEmit` OK ; biome propre.
3. Parcours UI dev : sélection → Sync → toast récap → métriques en table
   (pastille seranking) ; sélection vide → pas de barre ; kw pays exotique →
   « pays non couvert » dans le toast.
4. Vérifier qu'AUCUN fichier de `src/lib/backlink/import-core/` n'est commité
   (gitignoré — build local uniquement ; le deploy prod lance `build:wasm`).
5. Merge : `git checkout dev && git merge feature/seranking-enrichment --ff-only && git push && git branch -d feature/seranking-enrichment` (+ delete remote si pushée). **PAS de main/prod.**
