diff options
| author | Thanos Apollo <public@thanosapollo.org> | 2026-04-28 20:25:53 +0300 |
|---|---|---|
| committer | Thanos Apollo <public@thanosapollo.org> | 2026-04-28 20:25:53 +0300 |
| commit | 1feef310af7d076bedf1f3d030a4c5ec87ea84da (patch) | |
| tree | 69a54844f3f878cbc93c92ae7068c67d4756f73d | |
| parent | 0f4866d93a55d824d824bca4eaa3825663d2b902 (diff) | |
api, db: Add rate limiting, conditional requests, and timeouts
| -rw-r--r-- | lisp/forgejo-api.el | 320 | ||||
| -rw-r--r-- | lisp/forgejo-db.el | 103 | ||||
| -rw-r--r-- | tests/forgejo-test-api.el | 71 |
3 files changed, 390 insertions, 104 deletions
diff --git a/lisp/forgejo-api.el b/lisp/forgejo-api.el index 2618a14..8c29081 100644 --- a/lisp/forgejo-api.el +++ b/lisp/forgejo-api.el @@ -36,6 +36,22 @@ (declare-function forgejo-token "forgejo.el" (host-url)) (defvar forgejo--api-default-limit) +(defcustom forgejo-api-timeout 30 + "Seconds before an async API request times out. +When nil, no timeout is enforced." + :type '(choice (integer :tag "Seconds") + (const :tag "No timeout" nil)) + :group 'forgejo) + +(defcustom forgejo-api-rate-limit-warn-threshold 10 + "Warn when fewer than this many API requests remain." + :type 'integer + :group 'forgejo) + +(defvar forgejo-api--rate-limit-state (make-hash-table :test 'equal) + "Per-host rate limit state. +Keys are host URLs, values are plists with :remaining, :limit, :reset.") + ;;; URL building (defun forgejo-api--url (host endpoint &optional params) @@ -57,21 +73,37 @@ PARAMS is an alist of (KEY . VALUE) pairs for the query string." ;;; Response parsing (defun forgejo-api--parse-headers (buffer) - "Parse pagination headers from HTTP response BUFFER. -Returns a plist with :total-count and :link." + "Parse pagination and rate-limit headers from HTTP response BUFFER. +Returns a plist with :total-count, :link, :rate-limit-remaining, +:rate-limit-limit, and :rate-limit-reset." (with-current-buffer buffer (save-excursion (goto-char (point-min)) - (let (total-count link) - (while (re-search-forward "^\\([^:]+\\): \\(.+\\)\r?$" nil t) + (forward-line 1) + (let (total-count link rl-remaining rl-limit rl-reset etag last-modified) + (while (re-search-forward "^\\([^:\n]+\\): \\(.+\\)\r?$" nil t) (let ((name (downcase (match-string 1))) - (value (match-string 2))) + (value (string-trim-right (match-string 2)))) (cond ((string= name "x-total-count") (setq total-count (string-to-number value))) ((string= name "link") - (setq link value))))) - (list :total-count total-count :link link))))) + (setq link value)) + ((string= name "x-ratelimit-remaining") + (setq rl-remaining (string-to-number value))) + ((string= name "x-ratelimit-limit") + (setq rl-limit (string-to-number value))) + ((string= name "x-ratelimit-reset") + (setq rl-reset (string-to-number value))) + ((string= name "etag") + (setq etag value)) + ((string= name "last-modified") + (setq last-modified value))))) + (list :total-count total-count :link link + :rate-limit-remaining rl-remaining + :rate-limit-limit rl-limit + :rate-limit-reset rl-reset + :etag etag :last-modified last-modified))))) (defun forgejo-api--parse-response (buffer) "Parse the JSON body from HTTP response BUFFER. @@ -89,10 +121,51 @@ Returns the parsed JSON as alists/lists, or nil for empty bodies." (when (re-search-forward "^HTTP/[0-9.]+ \\([0-9]+\\)" nil t) (string-to-number (match-string 1))))) +;;; Rate limit helpers + +(defun forgejo-api--format-reset-time (unix-timestamp) + "Format UNIX-TIMESTAMP as a human-readable relative time string." + (if unix-timestamp + (let ((delta (- unix-timestamp (float-time)))) + (if (> delta 0) + (format "in %d min" (ceiling (/ delta 60))) + "now")) + "unknown")) + +(defun forgejo-api--check-rate-limit (host headers) + "Update rate limit state for HOST from HEADERS, warn if low." + (let ((remaining (plist-get headers :rate-limit-remaining))) + (when remaining + (puthash host + (list :remaining remaining + :limit (plist-get headers :rate-limit-limit) + :reset (plist-get headers :rate-limit-reset)) + forgejo-api--rate-limit-state) + (when (< remaining forgejo-api-rate-limit-warn-threshold) + (message "Forgejo API: %d requests remaining (resets %s)" + remaining + (forgejo-api--format-reset-time + (plist-get headers :rate-limit-reset))))))) + +(defun forgejo-api-rate-limit (host) + "Return the cached rate-limit plist for HOST, or nil." + (gethash host forgejo-api--rate-limit-state)) + +;;; Error helpers + +(defun forgejo-api--error-plist (http-status method endpoint message data) + "Build a structured error plist from components. +HTTP-STATUS is the numeric code (or nil for network errors). +METHOD and ENDPOINT identify the request. +MESSAGE is a human-readable error string. +DATA is the parsed response body, if any." + (list :status http-status :method method :endpoint endpoint + :message message :data data)) + ;;; Core request (defun forgejo-api--request (host method endpoint &optional params - json-body callback) + json-body callback &rest args) "Make an async HTTP request to the Forgejo API. HOST is the instance base URL (e.g. \"https://codeberg.org\"). @@ -102,53 +175,121 @@ PARAMS is an alist of query parameters. JSON-BODY, when non-nil, is an alist to encode as the request body. CALLBACK is called with two arguments: (RESPONSE-DATA HEADERS-PLIST). RESPONSE-DATA is the parsed JSON. - HEADERS-PLIST contains :total-count and :link." - (let ((url-request-method method) - (url-request-extra-headers - `(("Authorization" . ,(encode-coding-string - (concat "token " (forgejo-token host)) - 'ascii)) - ("Accept" . "application/json") - ,@(when json-body - '(("Content-Type" . "application/json"))))) - (url-request-data - (when json-body - (encode-coding-string (json-encode json-body) 'utf-8))) - (url (forgejo-api--url host endpoint params))) - (url-retrieve - url - (lambda (status) - (unwind-protect - (let ((http-status (forgejo-api--response-status (current-buffer)))) - (cond - ((and http-status (>= http-status 400)) - (let* ((err-data (condition-case nil - (forgejo-api--parse-response (current-buffer)) - (json-parse-error nil))) - (err-msg (when (listp err-data) - (alist-get 'message err-data)))) - (message "Forgejo API HTTP %d: %s %s%s" - http-status method endpoint - (if err-msg (concat " - " err-msg) "")))) - ((plist-get status :error) - (message "Forgejo API error: %S" (plist-get status :error))) - (t - (when callback - (let ((headers (forgejo-api--parse-headers (current-buffer))) - (data (forgejo-api--parse-response (current-buffer)))) - (funcall callback data headers)))))) - (kill-buffer (current-buffer)))) - nil t))) + HEADERS-PLIST contains :total-count and :link. + +ARGS is a plist of keyword options: + :error-callback -- function called with an error plist on failure. + The plist contains :status, :method, :endpoint, :message, :data. + When omitted, errors are logged to *Messages*. + :if-none-match -- ETag value for conditional requests. + :if-modified-since -- date string for conditional requests. + On 304 Not Modified, CALLBACK receives nil data." + (let* ((error-callback (plist-get args :error-callback)) + (if-none-match (plist-get args :if-none-match)) + (if-modified-since (plist-get args :if-modified-since)) + (completed (cons nil nil)) + (timeout-timer nil) + (url-request-method method) + (url-request-extra-headers + `(("Authorization" . ,(encode-coding-string + (concat "token " (forgejo-token host)) + 'ascii)) + ("Accept" . "application/json") + ,@(when json-body + '(("Content-Type" . "application/json"))) + ,@(when if-none-match + `(("If-None-Match" . ,if-none-match))) + ,@(when if-modified-since + `(("If-Modified-Since" . ,if-modified-since))))) + (url-request-data + (when json-body + (encode-coding-string (json-encode json-body) 'utf-8))) + (url (forgejo-api--url host endpoint params)) + (url-buf + (url-retrieve + url + (lambda (status) + (setcar completed t) + (when timeout-timer (cancel-timer timeout-timer)) + (unwind-protect + (let ((http-status (forgejo-api--response-status + (current-buffer)))) + (cond + ((and http-status (>= http-status 400)) + (forgejo-api--check-rate-limit + host (forgejo-api--parse-headers (current-buffer))) + (let* ((err-data (condition-case nil + (forgejo-api--parse-response + (current-buffer)) + (json-parse-error nil))) + (err-msg (when (listp err-data) + (alist-get 'message err-data))) + (error-info (forgejo-api--error-plist + http-status method endpoint + err-msg err-data))) + (if error-callback + (funcall error-callback error-info) + (message "Forgejo API HTTP %d: %s %s%s" + http-status method endpoint + (if err-msg + (concat " - " err-msg) ""))))) + ((plist-get status :error) + (let ((error-info + (forgejo-api--error-plist + nil method endpoint + (format "%S" (plist-get status :error)) + nil))) + (if error-callback + (funcall error-callback error-info) + (message "Forgejo API error: %S" + (plist-get status :error))))) + ((and http-status (= http-status 304)) + (let ((headers (forgejo-api--parse-headers + (current-buffer)))) + (forgejo-api--check-rate-limit host headers) + (when callback + (funcall callback nil headers)))) + (t + (let ((headers (forgejo-api--parse-headers + (current-buffer))) + (data (forgejo-api--parse-response + (current-buffer)))) + (forgejo-api--check-rate-limit host headers) + (when callback + (funcall callback data headers)))))) + (kill-buffer (current-buffer)))) + nil t))) + (when (and forgejo-api-timeout url-buf) + (setq timeout-timer + (run-at-time + forgejo-api-timeout nil + (lambda () + (unless (car completed) + (setcar completed t) + (when (buffer-live-p url-buf) + (let ((proc (get-buffer-process url-buf))) + (when proc (delete-process proc))) + (kill-buffer url-buf)) + (let ((error-info (forgejo-api--error-plist + nil method endpoint + "Request timed out" nil))) + (if error-callback + (funcall error-callback error-info) + (message "Forgejo API timeout: %s %s" + method endpoint)))))))))) ;;; Public wrappers -(defun forgejo-api-get (host endpoint &optional params callback) - "GET ENDPOINT on HOST with query PARAMS, call CALLBACK with (data headers)." - (forgejo-api--request host "GET" endpoint params nil callback)) +(defun forgejo-api-get (host endpoint &optional params callback &rest args) + "GET ENDPOINT on HOST with query PARAMS, call CALLBACK with (data headers). +ARGS accepts :error-callback for failure handling." + (apply #'forgejo-api--request host "GET" endpoint params nil callback args)) (defun forgejo-api-get-all (host endpoint &optional params callback) "GET all pages from ENDPOINT on HOST, call CALLBACK with (all-data headers). Fetches pages sequentially until all results are collected. +On mid-pagination failure, calls CALLBACK with partial data and +headers tagged with :partial t. PARAMS should include a \"limit\" entry. The \"page\" param is managed automatically." (let ((limit (or (cdr (assoc "limit" params)) "30")) @@ -170,14 +311,22 @@ managed automatically." (setq page (1+ page)) (fetch-page)) (when callback - (funcall callback accum headers))))))))) + (funcall callback accum headers))))) + :error-callback + (lambda (error-info) + (when callback + (funcall callback accum + (list :total-count nil :link nil + :partial t :error error-info)))))))) (fetch-page)))) (defun forgejo-api-get-paged (host endpoint params page-callback &optional done-callback) "GET all pages from ENDPOINT on HOST, calling PAGE-CALLBACK after each. PAGE-CALLBACK receives (PAGE-DATA HEADERS PAGE-NUMBER). -DONE-CALLBACK receives (ALL-DATA HEADERS) when all pages are fetched." +DONE-CALLBACK receives (ALL-DATA HEADERS) when all pages are fetched. +On mid-pagination failure, calls DONE-CALLBACK with partial data and +headers tagged with :partial t." (let ((limit (or (cdr (assoc "limit" params)) "50")) (accum nil) (page 1)) @@ -199,24 +348,69 @@ DONE-CALLBACK receives (ALL-DATA HEADERS) when all pages are fetched." (setq page (1+ page)) (fetch-page)) (when done-callback - (funcall done-callback accum headers))))))))) + (funcall done-callback accum headers))))) + :error-callback + (lambda (error-info) + (when done-callback + (funcall done-callback accum + (list :total-count nil :link nil + :partial t :error error-info)))))))) (fetch-page)))) -(defun forgejo-api-post (host endpoint &optional params json-body callback) - "POST to ENDPOINT on HOST with PARAMS and JSON-BODY, call CALLBACK." - (forgejo-api--request host "POST" endpoint params json-body callback)) +(defun forgejo-api-post (host endpoint &optional params json-body callback + &rest args) + "POST to ENDPOINT on HOST with PARAMS and JSON-BODY, call CALLBACK. +ARGS accepts :error-callback for failure handling." + (apply #'forgejo-api--request host "POST" endpoint params json-body + callback args)) + +(defun forgejo-api-patch (host endpoint &optional json-body callback &rest args) + "PATCH ENDPOINT on HOST with JSON-BODY, call CALLBACK. +ARGS accepts :error-callback for failure handling." + (apply #'forgejo-api--request host "PATCH" endpoint nil json-body + callback args)) + +(defun forgejo-api-put (host endpoint &optional json-body callback &rest args) + "PUT ENDPOINT on HOST with JSON-BODY, call CALLBACK. +ARGS accepts :error-callback for failure handling." + (apply #'forgejo-api--request host "PUT" endpoint nil json-body + callback args)) + +(defun forgejo-api-delete (host endpoint &optional json-body callback &rest args) + "DELETE ENDPOINT on HOST with optional JSON-BODY, call CALLBACK. +ARGS accepts :error-callback for failure handling." + (apply #'forgejo-api--request host "DELETE" endpoint nil json-body + callback args)) -(defun forgejo-api-patch (host endpoint &optional json-body callback) - "PATCH ENDPOINT on HOST with JSON-BODY, call CALLBACK." - (forgejo-api--request host "PATCH" endpoint nil json-body callback)) +;;; Conditional requests -(defun forgejo-api-put (host endpoint &optional json-body callback) - "PUT ENDPOINT on HOST with JSON-BODY, call CALLBACK." - (forgejo-api--request host "PUT" endpoint nil json-body callback)) +(declare-function forgejo-db-get-cache-headers "forgejo-db.el" + (host owner repo endpoint)) +(declare-function forgejo-db-set-cache-headers "forgejo-db.el" + (host owner repo endpoint etag last-modified)) -(defun forgejo-api-delete (host endpoint &optional json-body callback) - "DELETE ENDPOINT on HOST with optional JSON-BODY, call CALLBACK." - (forgejo-api--request host "DELETE" endpoint nil json-body callback)) +(defun forgejo-api-get-conditional (host endpoint params + cache-key callback &rest args) + "GET with conditional headers from DB cache. +CACHE-KEY is a list (HOST OWNER REPO ENDPOINT-NAME) for cache lookup. +On 304, calls CALLBACK with nil data. On 200, updates cached headers. +ARGS are passed through to `forgejo-api-get'." + (let* ((cached (apply #'forgejo-db-get-cache-headers cache-key)) + (etag (plist-get cached :etag)) + (last-mod (plist-get cached :last-modified))) + (apply #'forgejo-api-get host endpoint params + (lambda (data headers) + (when data + (let ((new-etag (plist-get headers :etag)) + (new-last-mod (plist-get headers :last-modified))) + (when (or new-etag new-last-mod) + (apply #'forgejo-db-set-cache-headers + (append cache-key + (list new-etag new-last-mod)))))) + (when callback (funcall callback data headers))) + :if-none-match etag + :if-modified-since last-mod + args))) ;;; Instance settings diff --git a/lisp/forgejo-db.el b/lisp/forgejo-db.el index be45cdf..22ce1b1 100644 --- a/lisp/forgejo-db.el +++ b/lisp/forgejo-db.el @@ -102,6 +102,8 @@ repo TEXT NOT NULL, endpoint TEXT NOT NULL, last_synced TEXT, + etag TEXT, + last_modified TEXT, PRIMARY KEY (host, owner, repo, endpoint))" ) "SQL statements to initialize the database schema.") @@ -210,21 +212,21 @@ When IS-PULL is non-nil, mark all entries as pull requests regardless of whether a `pull_request' field is present in the data." (setq owner (downcase owner) repo (downcase repo)) (forgejo-db--with-transaction - (let ((db (forgejo-db--ensure))) - (dolist (issue issues) - (let-alist issue - (let ((body (forgejo-db--nullable .body)) - (labels (forgejo-db--nullable .labels)) - (milestone (forgejo-db--nullable .milestone)) - (assignees (forgejo-db--nullable .assignees)) - (closed (forgejo-db--nullable .closed_at)) - (pr (or is-pull (forgejo-db--nullable .pull_request)))) - ;; Track edits before overwriting - (when body - (forgejo-db--track-edit db host owner repo .number body)) - (sqlite-execute - db - "INSERT INTO issues + (let ((db (forgejo-db--ensure))) + (dolist (issue issues) + (let-alist issue + (let ((body (forgejo-db--nullable .body)) + (labels (forgejo-db--nullable .labels)) + (milestone (forgejo-db--nullable .milestone)) + (assignees (forgejo-db--nullable .assignees)) + (closed (forgejo-db--nullable .closed_at)) + (pr (or is-pull (forgejo-db--nullable .pull_request)))) + ;; Track edits before overwriting + (when body + (forgejo-db--track-edit db host owner repo .number body)) + (sqlite-execute + db + "INSERT INTO issues (id, host, owner, repo, number, title, state, body, user, labels, milestone, assignees, comments_count, created_at, updated_at, closed_at, is_pull, read) @@ -235,16 +237,16 @@ whether a `pull_request' field is present in the data." assignees=excluded.assignees, comments_count=excluded.comments_count, updated_at=excluded.updated_at, closed_at=excluded.closed_at, is_pull=excluded.is_pull, read=0" - (list .id host owner repo .number .title .state body - (alist-get 'login .user) - (forgejo-db--encode-json (when (listp labels) labels)) - (when (listp milestone) (alist-get 'title milestone)) - (forgejo-db--encode-json - (when (listp assignees) - (mapcar (lambda (a) (alist-get 'login a)) assignees))) - .comments - .created_at .updated_at closed - (if pr 1 0))))))))) + (list .id host owner repo .number .title .state body + (alist-get 'login .user) + (forgejo-db--encode-json (when (listp labels) labels)) + (when (listp milestone) (alist-get 'title milestone)) + (forgejo-db--encode-json + (when (listp assignees) + (mapcar (lambda (a) (alist-get 'login a)) assignees))) + .comments + .created_at .updated_at closed + (if pr 1 0))))))))) (defun forgejo-db--like (s) "Wrap S in SQL LIKE wildcards." @@ -321,12 +323,12 @@ FILTERS is a plist with keys: (setq owner (downcase owner) repo (downcase repo)) (when (and events (listp events)) (forgejo-db--with-transaction - (let ((db (forgejo-db--ensure))) - (dolist (event events) - (let-alist event - (sqlite-execute - db - "INSERT INTO timeline_events + (let ((db (forgejo-db--ensure))) + (dolist (event events) + (let-alist event + (sqlite-execute + db + "INSERT INTO timeline_events (id, host, owner, repo, issue_number, type, body, user, created_at, data) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) @@ -334,12 +336,12 @@ FILTERS is a plist with keys: type=excluded.type, body=excluded.body, user=excluded.user, created_at=excluded.created_at, data=excluded.data" - (list .id host owner repo number - (forgejo-db--nullable .type) - (forgejo-db--nullable .body) - (alist-get 'login (forgejo-db--nullable .user)) - (forgejo-db--nullable .created_at) - (forgejo-db--encode-json event))))))))) + (list .id host owner repo number + (forgejo-db--nullable .type) + (forgejo-db--nullable .body) + (alist-get 'login (forgejo-db--nullable .user)) + (forgejo-db--nullable .created_at) + (forgejo-db--encode-json event))))))))) (defun forgejo-db-get-timeline (host owner repo number) "Get cached timeline events for issue NUMBER in HOST/OWNER/REPO." @@ -458,11 +460,32 @@ FILTERS is a plist with keys: "Set the last sync TIME for ENDPOINT in HOST/OWNER/REPO." (setq owner (downcase owner) repo (downcase repo)) (forgejo-db--execute - "INSERT OR REPLACE INTO sync_state - (host, owner, repo, endpoint, last_synced) - VALUES (?, ?, ?, ?, ?)" + "INSERT INTO sync_state (host, owner, repo, endpoint, last_synced) + VALUES (?, ?, ?, ?, ?) + ON CONFLICT (host, owner, repo, endpoint) + DO UPDATE SET last_synced = excluded.last_synced" (list host owner repo endpoint time))) +(defun forgejo-db-get-cache-headers (host owner repo endpoint) + "Get cached ETag and Last-Modified for ENDPOINT in HOST/OWNER/REPO." + (setq owner (downcase owner) repo (downcase repo)) + (let ((row (car (forgejo-db--select + "SELECT etag, last_modified FROM sync_state + WHERE host = ? AND owner = ? AND repo = ? AND endpoint = ?" + (list host owner repo endpoint))))) + (when row + (list :etag (nth 0 row) :last-modified (nth 1 row))))) + +(defun forgejo-db-set-cache-headers (host owner repo endpoint etag last-modified) + "Update cached ETAG and LAST-MODIFIED for ENDPOINT in HOST/OWNER/REPO." + (setq owner (downcase owner) repo (downcase repo)) + (forgejo-db--execute + "INSERT INTO sync_state (host, owner, repo, endpoint, etag, last_modified) + VALUES (?, ?, ?, ?, ?, ?) + ON CONFLICT (host, owner, repo, endpoint) + DO UPDATE SET etag = excluded.etag, last_modified = excluded.last_modified" + (list host owner repo endpoint etag last-modified))) + ;;; Row-to-alist conversion (explicit column queries) (defconst forgejo-db--issue-columns diff --git a/tests/forgejo-test-api.el b/tests/forgejo-test-api.el index c93b12e..06baefe 100644 --- a/tests/forgejo-test-api.el +++ b/tests/forgejo-test-api.el @@ -100,6 +100,23 @@ (insert "HTTP/1.1 200 OK\r\n\r\n{}") (should (= (forgejo-api--response-status (current-buffer)) 200)))) +(ert-deftest forgejo-test-api-response-status-304 () + "Extract 304 status code for conditional requests." + (with-temp-buffer + (insert "HTTP/1.1 304 Not Modified\r\n\r\n") + (should (= (forgejo-api--response-status (current-buffer)) 304)))) + +(ert-deftest forgejo-test-api-parse-headers-etag () + "Parse ETag and Last-Modified headers." + (with-temp-buffer + (insert "HTTP/1.1 200 OK\r\n" + "ETag: \"abc123\"\r\n" + "Last-Modified: Tue, 01 Apr 2026 12:00:00 GMT\r\n" + "\r\n{}") + (let ((headers (forgejo-api--parse-headers (current-buffer)))) + (should (string= (plist-get headers :etag) "\"abc123\"")) + (should (string-match-p "Tue" (plist-get headers :last-modified)))))) + ;;; Group 5: Auth (ert-deftest forgejo-test-api-token-from-variable () @@ -116,7 +133,59 @@ (forgejo-token nil)) (should-error (forgejo-token "https://codeberg.org") :type 'user-error))) -;;; Group 6: Default limit +;;; Group 6: Header parsing with rate limits + +(ert-deftest forgejo-test-api-parse-headers-rate-limit () + "Parse rate limit headers from HTTP response." + (with-temp-buffer + (insert "HTTP/1.1 200 OK\r\n" + "X-Total-Count: 10\r\n" + "X-RateLimit-Remaining: 42\r\n" + "X-RateLimit-Limit: 300\r\n" + "X-RateLimit-Reset: 1700000000\r\n" + "\r\n{}") + (let ((headers (forgejo-api--parse-headers (current-buffer)))) + (should (= (plist-get headers :total-count) 10)) + (should (= (plist-get headers :rate-limit-remaining) 42)) + (should (= (plist-get headers :rate-limit-limit) 300)) + (should (= (plist-get headers :rate-limit-reset) 1700000000))))) + +;;; Group 7: Rate limit helpers + +(ert-deftest forgejo-test-api-format-reset-time-future () + "Format a future reset timestamp as relative minutes." + (let ((future (+ (float-time) 300))) + (should (string-match-p "in [0-9]+ min" + (forgejo-api--format-reset-time future))))) + +(ert-deftest forgejo-test-api-format-reset-time-past () + "Format a past reset timestamp as now." + (let ((past (- (float-time) 10))) + (should (string= "now" (forgejo-api--format-reset-time past))))) + +(ert-deftest forgejo-test-api-format-reset-time-nil () + "Format nil timestamp as unknown." + (should (string= "unknown" (forgejo-api--format-reset-time nil)))) + +;;; Group 8: Error plist construction + +(ert-deftest forgejo-test-api-error-plist () + "Build a structured error plist from components." + (let ((plist (forgejo-api--error-plist 404 "GET" "repos/x/y" "Not Found" nil))) + (should (= (plist-get plist :status) 404)) + (should (string= (plist-get plist :method) "GET")) + (should (string= (plist-get plist :endpoint) "repos/x/y")) + (should (string= (plist-get plist :message) "Not Found")) + (should (null (plist-get plist :data))))) + +(ert-deftest forgejo-test-api-error-plist-network () + "Error plist for network errors has nil status." + (let ((plist (forgejo-api--error-plist nil "POST" "repos/x/y/issues" + "connection refused" nil))) + (should (null (plist-get plist :status))) + (should (string= (plist-get plist :message) "connection refused")))) + +;;; Group 9: Default limit (ert-deftest forgejo-test-api-default-limit-cached () "Return cached limit when available." |
